<?php
/**
 * Postomatic Plugin - Post Maker API endpoint handler.
 *
 *  PHP Version 5.6
 *
 * @category Plugin
 * @package  Postomatic
 * @license  GPL-2.0-or-later https://www.gnu.org/licenses/gpl-2.0.html
 * @link     https://buildomatics.com/postomatics
 * @since    1.0.0
 */

namespace Postomatic\Api;

// Exit if accessed directly.
use Exception;
use Postomatic\Codemanager;
use Postomatic\Constants;
use Postomatic\Core\Activator;
use Postomatic\Utils;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use wpdb;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}


/**
 * Handles the creation of posts through the REST API endpoint.
 *
 * This class manages the Post Maker API endpoint for programmatic WordPress post
 * creation. Includes SEO plugin support, media attachments, API validation and
 * one-time codes with data sanitization.
 *
 * @category   Plugin
 * @subpackage Api
 * @license    GPL-2.0-or-later https://www.gnu.org/licenses/gpl-2.0.html
 * @link       https://buildomatics.com/postomatics
 * @since      1.0.0
 */
class Post_Maker {

	/**
	 * Code manager instance.
	 *
	 * @var Codemanager
	 */
	private $code_manager;

	/**
	 * WordPress database instance.
	 *
	 * @var wpdb
	 */
	private $wpdb;

	/**
	 * Constructor.
	 *
	 * @param Codemanager $code_manager Code manager instance.
	 */
	public function __construct( ?Codemanager $code_manager = null ) {
		$this->code_manager = $code_manager ?? new Codemanager();
		global $wpdb;
		$this->wpdb = $wpdb;

		add_action( 'rest_api_init', array( $this, 'register_post_maker_endpoint' ) );

		// Register AJAX hook for post generation.
		add_action( 'wp_ajax_postomatic_generate_post', array( $this, 'handle_ajax_generate_post' ) );
	}

	/**
	 * Initialize the Post_Maker class.
	 *
	 * @return void
	 */
	public static function init() {
		new self();
	}

	/**
	 * Register the post maker REST API endpoint.
	 *
	 * @return void
	 */
	public function register_post_maker_endpoint() {
		register_rest_route(
			Constants::API_WP_ENDPOINT_BASE,
			Constants::API_WP_ENDPOINT_POST_CREATOR,
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle_post_maker' ),
				'permission_callback' => array( $this, 'validate_api_request' ),
			)
		);
	}

	/**
	 * Validate the API request by checking the API key.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @return true|WP_Error True if valid, WP_Error otherwise.
	 */
	public function validate_api_request( WP_REST_Request $request ) {
		$api_key    = sanitize_text_field(
			$request->get_header( 'Authorization-Code' )
		);
		$stored_key = Utils::get_wp_api_secure_key();

		if ( ! $api_key || ! hash_equals( $stored_key, $api_key ) ) {
			return new WP_Error(
				'invalid_key',
				'Invalid API key',
				array( 'status' => 401 )
			);
		}

		return true;
	}

	/**
	 * Handle the post maker API request.
	 *
	 * @param WP_REST_Request $request The request object.
	 *
	 * @throws Exception                 When post creation fails.
	 * @return WP_REST_Response|WP_Error Response or error.
	 */
	public function handle_post_maker( WP_REST_Request $request ) {
		// Require WordPress media handling functions.
		if ( ! function_exists( 'media_sideload_image' ) ) {
			include_once ABSPATH . 'wp-admin/includes/media.php';
			include_once ABSPATH . 'wp-admin/includes/file.php';
			include_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$params = $request->get_params();

		/*
		 * Validate before sanitizing. The content sanitizer used to run first
		 * and was handed $params['content'] unchecked, so a payload without a
		 * content key passed null into wp_kses() (a deprecation on PHP 8.1+)
		 * before the required-field check had a chance to reject it.
		 *
		 * 'author' and 'category' are deliberately not required: both have full
		 * resolution chains further down (resolve_post_author() and
		 * handle_categories()), and rejecting the callback over a missing
		 * optional field threw away a generation the user had already paid for.
		 */
		$required = array(
			'title',
			'content',
			'one_time_code',
		);
		foreach ( $required as $field ) {
			if ( empty( $params[ $field ] ) ) {
				return new WP_Error(
					'missing_field',
					"Missing required field: $field",
					array( 'status' => 400 )
				);
			}
		}

		$params['content'] = $this->sanitize_generated_content( $params['content'] );

		if ( '' === trim( wp_strip_all_tags( $params['content'] ) ) ) {
			return new WP_Error(
				'empty_content',
				'The provided content is empty after sanitization.',
				array( 'status' => 400 )
			);
		}

		// Validate one-time code.
		$one_time_code = $params['one_time_code'];
		if ( ! $this->code_manager->code_exists( $one_time_code ) ) {
			return new WP_Error(
				'invalid_code',
				'The provided code is invalid or has expired.',
				array( 'status' => 400 )
			);
		}

		// Prepare post data.
		$post_data = $this->prepare_post_data( $params );

		/*
		 * Temporarily remove content filtering so the pre-sanitized HTML in
		 * $post_data['post_content'] (already passed through
		 * sanitize_generated_content() above) is not double-filtered by
		 * wp_insert_post().
		 *
		 * This must always be restored, including on the success path. The
		 * previous implementation only restored these filters on the failure
		 * branch below, so every successful post creation left
		 * 'content_save_pre' / 'content_filtered_save_pre' permanently
		 * unfiltered for the rest of the request. Any other code running
		 * later in the same request (other hooks, other post saves,
		 * long-running / persistent PHP workers) would then save post content
		 * without WordPress's own kses filtering — a real conflict with core
		 * behavior. wp_insert_post() is now wrapped in try/finally so the
		 * filters are restored unconditionally, regardless of outcome.
		 */
		remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
		remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );

		try {
			// Create post.
			$post_id = wp_insert_post( $post_data );
		} finally {
			// Restore content filtering unconditionally.
			add_filter( 'content_save_pre', 'wp_filter_post_kses' );
			add_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );
		}

		if ( $post_id && ! is_wp_error( $post_id ) ) {
			$post_id = (int) $post_id;

			$this->assign_tags( $post_id, $params );

			/*
			 * Prefer a dedicated SEO title when the API sends one. This used to
			 * be hardcoded to the post title, so any SEO title the generator
			 * produced was discarded.
			 */
			$params['seo_title']              = $this->first_non_empty_string( $params, array( 'seo_title', 'meta_seo_title', 'title' ) );
			$params['seo_description']        = $this->first_non_empty_string( $params, array( 'meta_seo_desc', 'seo_description', 'meta_description' ) );
			$params['seo_focus_keyphrase']    = $this->first_non_empty_string( $params, array( 'one_focused_keyword', 'focus_keyword', 'seo_focus_keyphrase' ) );
			$params['seo_secondary_keywords'] = $this->first_non_empty_string( $params, array( 'secondary_keywords', 'seo_secondary_keywords' ) );

			/*
			 * Categories are assigned before the SEO metadata so the primary
			 * term is known and can be handed to plugins that store it.
			 */
			$assigned_categories = $this->handle_categories( $post_id, $params );
			$featured_image_id   = $this->handle_feature_image( $post_id, $params );

			$this->fill_seo_plugin_info( $post_id, $params, $assigned_categories );

			$assigned_tags = wp_get_post_tags( $post_id, array( 'fields' => 'names' ) );

			return rest_ensure_response(
				array(
					'success'            => true,
					'post_id'            => $post_id,
					'link'               => get_permalink( $post_id ),
					// Built directly rather than via get_edit_post_link(), which
					// needs a logged-in user and so always returns null here.
					'edit_link'          => admin_url( 'post.php?post=' . $post_id . '&action=edit' ),
					'final_title'        => get_the_title( $post_id ),
					/*
					 * The resolved state of the post, so the remote side records
					 * what WordPress actually stored instead of assuming its
					 * request was applied verbatim. post_status in particular can
					 * differ from what was asked for (see resolve_post_status()).
					 */
					'post_status'        => get_post_status( $post_id ),
					'post_name'          => get_post_field( 'post_name', $post_id ),
					'post_date'          => get_post_field( 'post_date', $post_id ),
					'post_date_gmt'      => get_post_field( 'post_date_gmt', $post_id ),
					'author_id'          => (int) get_post_field( 'post_author', $post_id ),
					'featured_image_id'  => $featured_image_id,
					'featured_image_url' => $featured_image_id ? (string) wp_get_attachment_image_url( $featured_image_id, 'full' ) : '',
					'assigned_categories' => $this->format_terms( $assigned_categories ),
					'assigned_tags'      => is_array( $assigned_tags ) && ! is_wp_error( $assigned_tags ) ? $assigned_tags : array(),
					'message'            => 'Post created successfully',
					'categories'         => array_map(
						function ( $category ) {
							return $category->name;
						},
						get_categories( array( 'hide_empty' => false ) )
					),
					// Same list as 'categories' but with ids and slugs, so the
					// remote side can match on something stable instead of a
					// display name.
					'site_categories'    => $this->format_terms( get_categories( array( 'hide_empty' => false ) ) ),
					'last_post_titles'   => $this->get_last_post_titles( 150 ),
				)
			);
		}

		// Handle post creation error.
		if ( is_wp_error( $post_id ) ) {
			$error_message = $post_id->get_error_message();
			throw new Exception(
				'Post Creation error:.' .
				wp_json_encode( $error_message )
			);
		}

		return new WP_Error(
			'post_creation_failed',
			'Unable to create post. Please check provided data or try again later.',
			array(
				'status'        => 500,
				'post_data'     => $post_data,
				'error_message' => 'Post ID returned 0 or false.',
				'timestamp'     => current_time( 'mysql' ),
			)
		);
	}

	/**
	 * Resolve the author for a post to a real, existing user ID.
	 *
	 * Posts were occasionally created with no author. The previous code ran
	 * `absint( $params['author'] )` and passed the result straight to
	 * `wp_insert_post()`. That turns anything non-numeric into 0, and
	 * `wp_insert_post()` stores 0 as-is rather than substituting a default, so
	 * the post ends up with no author at all. This happened when:
	 *
	 * - the value arrived as a display name rather than an ID (the admin UI
	 *   sends the literal string "None" when it cannot match the selected ID
	 *   against its own author list);
	 * - the value was a numeric ID for a user who had since been deleted, which
	 *   is easy to hit because AutoPilot tasks are created once and then run for
	 *   weeks afterwards;
	 * - the field was missing entirely from the callback payload.
	 *
	 * Resolution order: numeric ID of an existing user, then a lookup by login /
	 * slug / email / display name, then the fallbacks in
	 * `get_default_post_author()`. The return value is always a usable user ID.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return int Existing user ID to attribute the post to.
	 */
	private function resolve_post_author( array $params ): int {
		$candidates = array();

		if ( isset( $params['author'] ) ) {
			$candidates[] = $params['author'];
		}

		// Alternative field names used elsewhere in the plugin and by the API.
		foreach ( array( 'author_id', 'wordpress_author_id', 'author_name', 'wordpress_author_name' ) as $key ) {
			if ( isset( $params[ $key ] ) ) {
				$candidates[] = $params[ $key ];
			}
		}

		foreach ( $candidates as $candidate ) {
			if ( is_array( $candidate ) || is_object( $candidate ) ) {
				continue;
			}

			$candidate = trim( (string) $candidate );

			if ( '' === $candidate || 'None' === $candidate || 'null' === $candidate ) {
				continue;
			}

			// A numeric value is only trusted once the user is known to exist.
			if ( ctype_digit( $candidate ) ) {
				$user_id = absint( $candidate );

				if ( $user_id > 0 && get_userdata( $user_id ) ) {
					return $user_id;
				}

				continue;
			}

			// Non-numeric: treat it as a login, slug, email or display name.
			$user_id = $this->find_user_by_name( $candidate );

			if ( $user_id > 0 ) {
				return $user_id;
			}
		}

		return $this->get_default_post_author();
	}

	/**
	 * Look up a user ID from a login, slug, email address or display name.
	 *
	 * @param string $name Value to search for.
	 *
	 * @return int User ID, or 0 when nothing matches.
	 */
	private function find_user_by_name( string $name ): int {
		$field = is_email( $name ) ? 'email' : 'login';
		$user  = get_user_by( $field, $name );

		if ( ! $user ) {
			$user = get_user_by( 'slug', sanitize_title( $name ) );
		}

		if ( ! $user ) {
			// Display names are not unique, so this is the last resort.
			$matches = get_users(
				array(
					'search'         => $name,
					'search_columns' => array( 'display_name', 'user_nicename', 'user_login' ),
					'number'         => 1,
					'fields'         => 'ID',
				)
			);

			if ( ! empty( $matches ) ) {
				return absint( $matches[0] );
			}

			return 0;
		}

		return absint( $user->ID );
	}

	/**
	 * Pick a sensible author when the request does not identify a valid one.
	 *
	 * Prefers the current user, which covers the AJAX path where an admin is
	 * generating a post interactively. The REST path is authenticated by API key
	 * rather than by cookie, so there is no current user there and we fall back
	 * to any user who can publish posts.
	 *
	 * @return int Existing user ID. 0 only if the site somehow has no users.
	 */
	private function get_default_post_author(): int {
		$current_user_id = get_current_user_id();

		if ( $current_user_id > 0 ) {
			return $current_user_id;
		}

		// Prefer an administrator, then any role that can publish posts.
		foreach ( array( 'administrator', 'editor', 'author' ) as $role ) {
			$users = get_users(
				array(
					'role'    => $role,
					'number'  => 1,
					'orderby' => 'ID',
					'order'   => 'ASC',
					'fields'  => 'ID',
				)
			);

			if ( ! empty( $users ) ) {
				return absint( $users[0] );
			}
		}

		$any_user = get_users(
			array(
				'number'  => 1,
				'orderby' => 'ID',
				'order'   => 'ASC',
				'fields'  => 'ID',
			)
		);

		return empty( $any_user ) ? 0 : absint( $any_user[0] );
	}

	/**
	 * Prepare post data for insertion.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return array Post data array.
	 */
	private function prepare_post_data( $params ): array {
		$publish_timestamp = $this->resolve_publish_timestamp( $params );

		$post_data = array(
			'post_title'     => sanitize_text_field( $params['title'] ),
			// Already sanitized once in handle_post_maker() with the allowlist
			// from get_allowed_content_html(); running wp_kses_post() again here
			// stripped tags that allowlist deliberately permits.
			'post_content'   => (string) $params['content'],
			'post_author'    => $this->resolve_post_author( $params ),
			'post_status'    => $this->resolve_post_status( $params, $publish_timestamp ),
			'post_type'      => 'post',
			'post_excerpt'   => $this->resolve_excerpt( $params ),
			'comment_status' => $this->resolve_comment_status( $params ),
			'meta_input'     => $this->resolve_post_meta( $params ),
		);

		// Trackbacks/pingbacks follow the comment setting rather than being left
		// at the WordPress default, which is what users expect from one toggle.
		$post_data['ping_status'] = $post_data['comment_status'];

		/*
		 * An explicit slug, when the generator supplies one. Useful for
		 * non-Latin titles, where WordPress would otherwise derive a
		 * percent-encoded slug from the title.
		 */
		$slug = $this->resolve_slug( $params );
		if ( '' !== $slug ) {
			$post_data['post_name'] = $slug;
		}

		if ( null !== $publish_timestamp ) {
			/*
			 * Both dates are set from one timestamp. The previous version built
			 * the local date by string concatenation from $params['year'] …
			 * $params['minute'] without checking any of them were present (a
			 * missing key was an undefined-index warning, and on PHP 8 the
			 * warning-to-string coercion produced dates like "-00-00 00:00:00"),
			 * then derived GMT from that. resolve_publish_timestamp() validates
			 * the whole thing up front instead.
			 */
			$post_data['post_date']     = wp_date( 'Y-m-d H:i:s', $publish_timestamp );
			$post_data['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $publish_timestamp );
		}

		return $post_data;
	}

	/**
	 * Return the first non-empty string value among a set of parameter keys.
	 *
	 * The remote API is not consistent about field naming between the manual and
	 * AutoPilot flows, so most fields have to be looked up under several names.
	 *
	 * @param array    $params Request parameters.
	 * @param string[] $keys   Keys to try, in order of preference.
	 *
	 * @return string Trimmed value, or an empty string when none matched.
	 */
	private function first_non_empty_string( array $params, array $keys ): string {
		foreach ( $keys as $key ) {
			if ( ! isset( $params[ $key ] ) ) {
				continue;
			}

			$value = $params[ $key ];

			if ( is_array( $value ) || is_object( $value ) || is_bool( $value ) ) {
				continue;
			}

			$value = trim( (string) $value );

			if ( '' !== $value ) {
				return $value;
			}
		}

		return '';
	}

	/**
	 * Resolve the post status to a status WordPress actually recognises.
	 *
	 * The admin form encodes the plan tier into its option values
	 * ("draft-free", "schedule", …) and the API echoes those values back here.
	 * They were previously passed straight to wp_insert_post(), which stores an
	 * unknown status verbatim: the post then belongs to no status WordPress
	 * lists, so it shows up in neither Drafts nor Published and is effectively
	 * invisible in the admin.
	 *
	 * @param array    $params            Request parameters.
	 * @param int|null $publish_timestamp Resolved publish time, when there is one.
	 *
	 * @return string A registered post status.
	 */
	private function resolve_post_status( array $params, $publish_timestamp = null ): string {
		$status = strtolower( $this->first_non_empty_string( $params, array( 'post_status', 'status' ) ) );

		// Drop the plan-tier suffix the UI appends to its option values.
		$status = (string) preg_replace( '/[-_](free|starter|basic|pro|pro-ultimate|premium|ultimate)$/', '', $status );

		$aliases = array(
			''                => 'draft',
			'schedule'        => 'future',
			'scheduled'       => 'future',
			'pending-review'  => 'pending',
			'pending_review'  => 'pending',
			'review'          => 'pending',
			'immediately'     => 'publish',
			'publish-now'     => 'publish',
			'published'       => 'publish',
			'live'            => 'publish',
		);

		if ( isset( $aliases[ $status ] ) ) {
			$status = $aliases[ $status ];
		}

		$allowed = array( 'draft', 'pending', 'publish', 'future', 'private' );

		if ( ! in_array( $status, $allowed, true ) ) {
			$status = 'draft';
		}

		$now = time();

		/*
		 * Keep status and date consistent. 'publish' with a future date makes
		 * WordPress publish immediately and silently lose the schedule, while
		 * 'future' without a future date leaves the post parked forever because
		 * the publish cron event never has a due date to fire on.
		 */
		if ( 'publish' === $status && null !== $publish_timestamp && $publish_timestamp > $now ) {
			$status = 'future';
		}

		if ( 'future' === $status && ( null === $publish_timestamp || $publish_timestamp <= $now ) ) {
			$status = 'publish';
		}

		return $status;
	}

	/**
	 * Resolve the publish time to a Unix timestamp.
	 *
	 * Accepts either a complete datetime string or the separate year/month/day/
	 * hour/minute fields the admin form submits. Bare datetime strings are read
	 * in the site's timezone, which is the timezone the scheduling UI shows.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return int|null Timestamp, or null when no usable date was supplied.
	 */
	private function resolve_publish_timestamp( array $params ) {
		$candidates = array();

		$datetime = $this->first_non_empty_string( $params, array( 'publish_date', 'post_date', 'scheduled_at', 'schedule_date' ) );

		if ( '' !== $datetime ) {
			// datetime-local inputs use "Y-m-dTH:i".
			$candidates[] = str_replace( 'T', ' ', $datetime );
		}

		if ( ! empty( $params['year'] ) && ! empty( $params['month'] ) && ! empty( $params['day'] ) ) {
			$candidates[] = sprintf(
				'%04d-%02d-%02d %02d:%02d:00',
				absint( $params['year'] ),
				absint( $params['month'] ),
				absint( $params['day'] ),
				isset( $params['hour'] ) ? absint( $params['hour'] ) : 0,
				isset( $params['minute'] ) ? absint( $params['minute'] ) : 0
			);
		}

		foreach ( $candidates as $candidate ) {
			try {
				$date = new \DateTime( $candidate, wp_timezone() );
			} catch ( Exception $e ) {
				continue;
			}

			$timestamp = $date->getTimestamp();

			// Guard against a zeroed or otherwise nonsensical date.
			if ( $timestamp > 0 ) {
				return $timestamp;
			}
		}

		return null;
	}

	/**
	 * Resolve the post excerpt.
	 *
	 * Falls back to the generated meta description so posts land with a real
	 * excerpt for themes, feeds and archive listings, rather than relying on
	 * WordPress trimming the opening of the article.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return string
	 */
	private function resolve_excerpt( array $params ): string {
		$excerpt = $this->first_non_empty_string(
			$params,
			array( 'excerpt', 'post_excerpt', 'summary', 'meta_seo_desc', 'seo_description' )
		);

		// sanitize_textarea_field() rather than sanitize_text_field(), which
		// collapses the line breaks out of a multi-line excerpt.
		return sanitize_textarea_field( $excerpt );
	}

	/**
	 * Resolve an explicit post slug, if one was supplied.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return string Sanitized slug, or an empty string to let WordPress derive one.
	 */
	private function resolve_slug( array $params ): string {
		$slug = $this->first_non_empty_string(
			$params,
			array( 'slug', 'post_slug', 'post_name', 'seo_slug', 'permalink_slug' )
		);

		return '' === $slug ? '' : sanitize_title( $slug );
	}

	/**
	 * Resolve whether comments should be open.
	 *
	 * When the payload says nothing either way, the site's own default for posts
	 * is used. Previously any missing flag closed comments, overriding the site
	 * setting for every generated post.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return string 'open' or 'closed'.
	 */
	private function resolve_comment_status( array $params ): string {
		if ( isset( $params['is_comment_enabled'] ) ) {
			return $params['is_comment_enabled'] ? 'open' : 'closed';
		}

		if ( isset( $params['comment_status'] ) ) {
			$value = strtolower( trim( (string) $params['comment_status'] ) );

			return in_array( $value, array( 'open', 'opened', '1', 'true', 'enabled', 'yes' ), true ) ? 'open' : 'closed';
		}

		return 'open' === get_default_comment_status( 'post' ) ? 'open' : 'closed';
	}

	/**
	 * Build the meta values stored alongside every generated post.
	 *
	 * These make plugin-created posts identifiable after the fact, which the
	 * posts previously were not: there was no way to tell a generated post from
	 * a hand-written one, or to trace one back to the AutoPilot task that
	 * produced it.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return array<string, mixed> Meta keys and values for wp_insert_post().
	 */
	private function resolve_post_meta( array $params ): array {
		$meta = array(
			'_postomatic_generated'    => '1',
			'_postomatic_generated_at' => current_time( 'mysql' ),
		);

		$autopilot_id = $this->first_non_empty_string(
			$params,
			array( 'autopilot_identifier', 'autopilot_id', 'task_identifier', 'task_id' )
		);

		if ( '' !== $autopilot_id ) {
			$meta['_postomatic_autopilot_id'] = sanitize_text_field( $autopilot_id );
		}

		$content_goal = $this->first_non_empty_string( $params, array( 'content_goal_type', 'title_type' ) );

		if ( '' !== $content_goal ) {
			$meta['_postomatic_content_goal'] = sanitize_text_field( $content_goal );
		}

		$language = $this->first_non_empty_string( $params, array( 'language', 'language_code' ) );

		if ( '' !== $language ) {
			$meta['_postomatic_language'] = sanitize_text_field( $language );
		}

		return $meta;
	}

	/**
	 * The HTML allowlist applied to generated article content.
	 *
	 * Built on top of WordPress's own post allowlist rather than the short
	 * hand-written list this used to carry. That list omitted tags the generator
	 * legitimately produces — figure/figcaption, hr, sup/sub, definition lists,
	 * table attributes, and any img attribute beyond src/alt/width/height/class
	 * — all of which were silently stripped out of paid-for content.
	 *
	 * @return array Allowed tags and attributes for wp_kses().
	 */
	private function get_allowed_content_html(): array {
		$allowed = wp_kses_allowed_html( 'post' );

		/*
		 * Embedded players, for the "Suggest Videos" option. WordPress's post
		 * allowlist has no iframe at all, so video embeds were being dropped
		 * entirely. The src host is checked separately in
		 * filter_untrusted_iframes(); this only decides which attributes may
		 * survive kses.
		 */
		$allowed['iframe'] = array(
			'src'             => true,
			'width'           => true,
			'height'          => true,
			'title'           => true,
			'class'           => true,
			'style'           => true,
			'loading'         => true,
			'allow'           => true,
			'allowfullscreen' => true,
			'frameborder'     => true,
			'scrolling'       => true,
			'referrerpolicy'  => true,
		);

		/**
		 * Filters the HTML allowed in generated post content.
		 *
		 * @param array $allowed Allowed tags and attributes for wp_kses().
		 */
		return apply_filters( 'postomatic_allowed_content_html', $allowed );
	}

	/**
	 * Sanitize generated article content.
	 *
	 * @param mixed $content Raw content from the API.
	 *
	 * @return string Sanitized HTML.
	 */
	private function sanitize_generated_content( $content ): string {
		if ( ! is_string( $content ) ) {
			return '';
		}

		return $this->filter_untrusted_iframes( wp_kses( $content, $this->get_allowed_content_html() ) );
	}

	/**
	 * Drop any iframe whose source is not a known media host.
	 *
	 * kses can allow the iframe tag but cannot restrict where it points, so the
	 * host allowlist is enforced here. Anything not on the list is removed
	 * rather than rewritten, so an unexpected embed can never load third-party
	 * script into the site's pages.
	 *
	 * @param string $content Content that has already been through wp_kses().
	 *
	 * @return string
	 */
	private function filter_untrusted_iframes( string $content ): string {
		if ( false === stripos( $content, '<iframe' ) ) {
			return $content;
		}

		/**
		 * Filters the hosts allowed as iframe embed sources in generated content.
		 *
		 * @param string[] $hosts Allowed hostnames.
		 */
		$hosts = apply_filters(
			'postomatic_allowed_embed_hosts',
			array(
				'youtube.com',
				'www.youtube.com',
				'youtube-nocookie.com',
				'www.youtube-nocookie.com',
				'youtu.be',
				'vimeo.com',
				'player.vimeo.com',
				'dailymotion.com',
				'www.dailymotion.com',
				'geo.dailymotion.com',
				'w.soundcloud.com',
				'open.spotify.com',
				'aparat.com',
				'www.aparat.com',
			)
		);

		$hosts = array_map( 'strtolower', (array) $hosts );

		$filtered = preg_replace_callback(
			'#<iframe\b[^>]*>.*?</iframe>|<iframe\b[^>]*/?>#is',
			function ( $matches ) use ( $hosts ) {
				if ( ! preg_match( '#\bsrc\s*=\s*("|\')(.*?)\1#i', $matches[0], $src ) ) {
					return '';
				}

				$host = wp_parse_url( html_entity_decode( $src[2] ), PHP_URL_HOST );

				if ( ! $host || ! in_array( strtolower( $host ), $hosts, true ) ) {
					return '';
				}

				return $matches[0];
			},
			$content
		);

		// preg_replace_callback() returns null on failure (e.g. backtrack limit
		// on very long content); keep the pre-filter string in that case rather
		// than wiping the article.
		return null === $filtered ? $content : $filtered;
	}

	/**
	 * Assign tags to the created post.
	 *
	 * Values are trimmed and de-duplicated. The previous implementation passed
	 * the raw result of explode(',', …) straight through, so " seo" and "seo"
	 * became two separate terms and a trailing comma created an empty one.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $params  Request parameters.
	 *
	 * @return void
	 */
	private function assign_tags( int $post_id, array $params ): void {
		$raw = null;

		foreach ( array( 'tags', 'post_tags', 'keywords_tags' ) as $key ) {
			if ( isset( $params[ $key ] ) && ( is_array( $params[ $key ] ) || is_string( $params[ $key ] ) ) ) {
				$raw = $params[ $key ];
				break;
			}
		}

		if ( null === $raw ) {
			return;
		}

		$candidates = is_array( $raw ) ? $raw : explode( ',', $raw );
		$tags       = array();

		foreach ( $candidates as $candidate ) {
			if ( is_array( $candidate ) || is_object( $candidate ) ) {
				continue;
			}

			$tag = trim( sanitize_text_field( (string) $candidate ) );

			if ( '' !== $tag && ! in_array( $tag, $tags, true ) ) {
				$tags[] = $tag;
			}
		}

		if ( empty( $tags ) ) {
			return;
		}

		wp_set_post_tags( $post_id, $tags, false );
	}

	/**
	 * Reduce term objects to id/name/slug triples for the API response.
	 *
	 * @param array $terms Term objects.
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private function format_terms( $terms ): array {
		if ( ! is_array( $terms ) ) {
			return array();
		}

		$formatted = array();

		foreach ( $terms as $term ) {
			if ( ! is_object( $term ) || ! isset( $term->term_id ) ) {
				continue;
			}

			$formatted[] = array(
				'id'   => (int) $term->term_id,
				'name' => $term->name,
				'slug' => $term->slug,
			);
		}

		return $formatted;
	}

	/**
	 * Fill SEO plugin metadata based on installed plugins.
	 *
	 * @param int   $post_id             Post ID.
	 * @param array $seo_data            SEO data parameters.
	 * @param array $assigned_categories Categories assigned to the post, used for the primary term.
	 *
	 * @return void
	 */
	private function fill_seo_plugin_info( $post_id, $seo_data, array $assigned_categories = array() ) {
		$seo_title     = sanitize_text_field( $seo_data['seo_title'] ?? $seo_data['title'] ?? '' );
		$seo_desc      = sanitize_text_field( $seo_data['seo_description'] ?? '' );
		$focus_kw      = sanitize_text_field( $seo_data['seo_focus_keyphrase'] ?? '' );
		$secondary_kws = sanitize_text_field( $seo_data['seo_secondary_keywords'] ?? '' );

		/*
		 * Social titles/descriptions. Without these, Facebook, X and other
		 * scrapers fall back to whatever the theme emits, so the generated SEO
		 * copy never reached a share card. Separate og_* / twitter_* values are
		 * used when supplied, otherwise the SEO title and description are
		 * reused.
		 */
		$og_title      = sanitize_text_field( $this->first_non_empty_string( $seo_data, array( 'og_title', 'social_title' ) ) );
		$og_desc       = sanitize_text_field( $this->first_non_empty_string( $seo_data, array( 'og_description', 'social_description' ) ) );
		$og_title      = '' !== $og_title ? $og_title : $seo_title;
		$og_desc       = '' !== $og_desc ? $og_desc : $seo_desc;
		$primary_cat_id = 0;

		if ( ! empty( $assigned_categories ) ) {
			$first = reset( $assigned_categories );

			if ( is_object( $first ) && isset( $first->term_id ) ) {
				$primary_cat_id = (int) $first->term_id;
			}
		}

		// Yoast SEO.
		if ( defined( 'WPSEO_VERSION' ) && class_exists( 'WPSEO_Meta' ) ) {
			update_post_meta( $post_id, '_yoast_wpseo_title', $seo_title );
			update_post_meta( $post_id, '_yoast_wpseo_metadesc', $seo_desc );

			if ( ! empty( $focus_kw ) ) {
				update_post_meta( $post_id, '_yoast_wpseo_focuskw', $focus_kw );
			}

			// Synonyms / secondary keywords (Yoast Premium).
			if ( ! empty( $secondary_kws ) ) {
				update_post_meta( $post_id, '_yoast_wpseo_focuskwsynonyms', $secondary_kws );
			}

			if ( '' !== $og_title ) {
				update_post_meta( $post_id, '_yoast_wpseo_opengraph-title', $og_title );
				update_post_meta( $post_id, '_yoast_wpseo_twitter-title', $og_title );
			}

			if ( '' !== $og_desc ) {
				update_post_meta( $post_id, '_yoast_wpseo_opengraph-description', $og_desc );
				update_post_meta( $post_id, '_yoast_wpseo_twitter-description', $og_desc );
			}

			// Yoast needs an explicit primary term when a post has more than one
			// category, otherwise its breadcrumbs and permalinks pick one at random.
			if ( $primary_cat_id ) {
				update_post_meta( $post_id, '_yoast_wpseo_primary_category', $primary_cat_id );
			}
		}

		// Rank Math SEO.
		if ( defined( 'RANKMATH_VERSION' ) && class_exists( 'RankMath\Post' ) ) {
			update_post_meta( $post_id, 'rank_math_title', $seo_title );
			update_post_meta( $post_id, 'rank_math_description', $seo_desc );

			// Rank Math stores focus + secondary as a comma-separated list in one field.
			// Use array_filter to avoid a leading/trailing comma when either value is empty.
			$rank_math_parts = array_filter( array( $focus_kw, $secondary_kws ) );
			if ( ! empty( $rank_math_parts ) ) {
				update_post_meta( $post_id, 'rank_math_focus_keyword', implode( ',', $rank_math_parts ) );
			}

			if ( '' !== $og_title ) {
				update_post_meta( $post_id, 'rank_math_facebook_title', $og_title );
				update_post_meta( $post_id, 'rank_math_twitter_title', $og_title );
			}

			if ( '' !== $og_desc ) {
				update_post_meta( $post_id, 'rank_math_facebook_description', $og_desc );
				update_post_meta( $post_id, 'rank_math_twitter_description', $og_desc );
			}

			if ( $primary_cat_id ) {
				update_post_meta( $post_id, 'rank_math_primary_category', $primary_cat_id );
			}
		}

		// All in One SEO.
		if ( defined( 'AIOSEO_VERSION' )
			&& class_exists( 'AIOSEO\Plugin\Common\Models\Post' )
		) {
			update_post_meta( $post_id, '_aioseo_title', $seo_title );
			update_post_meta( $post_id, '_aioseo_description', $seo_desc );

			// Build keyphrase list: start with focus keyword (if present), then secondary ones.
			$keyphrases = array();
			if ( ! empty( $focus_kw ) ) {
				$keyphrases[] = array( 'keyphrase' => $focus_kw );
			}
			if ( ! empty( $secondary_kws ) ) {
				foreach ( explode( ',', $secondary_kws ) as $kw ) {
					$kw = trim( $kw );
					if ( $kw ) {
						$keyphrases[] = array( 'keyphrase' => $kw );
					}
				}
			}
			if ( ! empty( $keyphrases ) ) {
				update_post_meta( $post_id, '_aioseo_keyphrases', wp_json_encode( $keyphrases ) );
			}

			if ( '' !== $og_title ) {
				update_post_meta( $post_id, '_aioseo_og_title', $og_title );
				update_post_meta( $post_id, '_aioseo_twitter_title', $og_title );
			}

			if ( '' !== $og_desc ) {
				update_post_meta( $post_id, '_aioseo_og_description', $og_desc );
				update_post_meta( $post_id, '_aioseo_twitter_description', $og_desc );
			}
		}

		// SEOPress.
		if ( defined( 'SEOPRESS_VERSION' )
			&& function_exists( 'seopress_get_service' )
		) {
			update_post_meta( $post_id, '_seopress_titles_title', $seo_title );
			update_post_meta( $post_id, '_seopress_titles_desc', $seo_desc );

			if ( ! empty( $focus_kw ) ) {
				update_post_meta( $post_id, '_seopress_analysis_target_kw', $focus_kw );
			}
			if ( ! empty( $secondary_kws ) ) {
				update_post_meta( $post_id, '_seopress_analysis_keywords', $secondary_kws );
			}

			if ( '' !== $og_title ) {
				update_post_meta( $post_id, '_seopress_social_fb_title', $og_title );
				update_post_meta( $post_id, '_seopress_social_twitter_title', $og_title );
			}

			if ( '' !== $og_desc ) {
				update_post_meta( $post_id, '_seopress_social_fb_desc', $og_desc );
				update_post_meta( $post_id, '_seopress_social_twitter_desc', $og_desc );
			}
		}

		// WP Meta SEO.
		if ( defined( 'WPMETA_SEO_VERSION' ) && class_exists( 'WPMeta_Seo' ) ) {
			update_post_meta( $post_id, '_metaseo_metatitle', $seo_title );
			update_post_meta( $post_id, '_metaseo_metadesc', $seo_desc );

			if ( ! empty( $focus_kw ) ) {
				update_post_meta( $post_id, '_metaseo_metakeywords', $focus_kw . ( $secondary_kws ? ', ' . $secondary_kws : '' ) );
			}
		}

		// The SEO Framework.
		// Note: uses THE_SEO_FRAMEWORK_VERSION, not WPSEO_PLUGIN_FILE which is a Yoast constant.
		if ( defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) {
			update_post_meta( $post_id, '_genesis_title', $seo_title );
			update_post_meta( $post_id, '_genesis_description', $seo_desc );

			if ( ! empty( $focus_kw ) ) {
				update_post_meta( $post_id, '_genesis_keywords', $focus_kw . ( $secondary_kws ? ', ' . $secondary_kws : '' ) );
			}
		}
	}

	/**
	 * Handle feature image upload and attachment.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $params  Request parameters.
	 *
	 * @return int Attachment ID that was set as the thumbnail, or 0 when none was.
	 */
	private function handle_feature_image( $post_id, $params ): int {
		/*
		 * One lookup instead of the three near-identical branches this used to
		 * be (numeric feature_image / URL feature_image / feature_image_url,
		 * each repeating the same same-site check and sideload).
		 */
		$source = $this->first_non_empty_string(
			$params,
			array( 'feature_image', 'feature_image_url', 'featured_image', 'featured_image_url', 'image_url' )
		);

		if ( '' === $source ) {
			return 0;
		}

		$image_id = $this->resolve_attachment_id( $source, (int) $post_id );

		if ( ! $image_id ) {
			return 0;
		}

		set_post_thumbnail( $post_id, $image_id );
		$this->maybe_set_image_alt_text( $image_id, $params );

		return $image_id;
	}

	/**
	 * Resolve an image reference to an attachment ID, importing it if needed.
	 *
	 * @param string $source  Attachment ID or image URL.
	 * @param int    $post_id Post the image belongs to.
	 *
	 * @return int Attachment ID, or 0 when the image could not be resolved.
	 */
	private function resolve_attachment_id( string $source, int $post_id ): int {
		if ( ctype_digit( $source ) ) {
			$image_id = absint( $source );

			return ( $image_id && wp_attachment_is_image( $image_id ) ) ? $image_id : 0;
		}

		// Anything that is not an ID has to be an http(s) URL; media_sideload_image()
		// would otherwise be handed a local path or data URI.
		if ( ! preg_match( '#^https?://#i', $source ) ) {
			return 0;
		}

		// Reuse the existing attachment when the image already lives on this
		// site, so re-runs do not pile up duplicates in the media library.
		$existing_id = attachment_url_to_postid( $source );

		if ( $existing_id ) {
			return (int) $existing_id;
		}

		try {
			$image_id = media_sideload_image( $source, $post_id, null, 'id' );
		} catch ( Exception $e ) {
			return 0;
		}

		if ( is_wp_error( $image_id ) || ! $image_id || ! wp_attachment_is_image( $image_id ) ) {
			return 0;
		}

		return (int) $image_id;
	}

	/**
	 * Give a newly imported image alt text.
	 *
	 * Sideloaded images previously arrived with no alt text at all, which is both
	 * an accessibility failure and a wasted SEO signal on every generated post.
	 * An alt text already on the attachment is left alone.
	 *
	 * @param int   $image_id Attachment ID.
	 * @param array $params   Request parameters.
	 *
	 * @return void
	 */
	private function maybe_set_image_alt_text( int $image_id, array $params ): void {
		$existing = get_post_meta( $image_id, '_wp_attachment_image_alt', true );

		if ( ! empty( $existing ) ) {
			return;
		}

		$alt = $this->first_non_empty_string(
			$params,
			array( 'feature_image_alt', 'featured_image_alt', 'image_alt', 'seo_title', 'title' )
		);

		if ( '' === $alt ) {
			return;
		}

		update_post_meta( $image_id, '_wp_attachment_image_alt', sanitize_text_field( $alt ) );
	}

	/**
	 * Handle categories assignment for the created post.
	 *
	 * Supports more than one category. The previous version took only the first
	 * element of an array and dropped the rest, so a multi-category assignment
	 * from the API silently became a single category.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $params  Request parameters.
	 *
	 * @return array Term objects actually assigned to the post.
	 */
	private function handle_categories( int $post_id, array $params ): array {
		$requested = $this->collect_requested_categories( $params );

		if ( empty( $requested ) ) {
			return array();
		}

		$term_ids = array();

		foreach ( $requested as $category ) {
			$term_id = $this->resolve_category_term_id( $category );

			if ( $term_id && ! in_array( $term_id, $term_ids, true ) ) {
				$term_ids[] = $term_id;
			}
		}

		if ( empty( $term_ids ) ) {
			return array();
		}

		wp_set_post_categories( $post_id, $term_ids );

		$assigned = get_the_category( $post_id );

		return is_array( $assigned ) ? $assigned : array();
	}

	/**
	 * Collect the categories the request asks for.
	 *
	 * Only 'category' (and its id/slug variants) is read. The 'categories'
	 * parameter is deliberately ignored: the admin form sends the site's whole
	 * category list under that name, so treating it as a selection would file
	 * every post under every category.
	 *
	 * A string value stays a single category. Comma splitting is not applied,
	 * because category names legitimately contain commas ("Health, Fitness")
	 * and splitting them would create two wrong terms; multiple categories are
	 * supported by sending an array.
	 *
	 * @param array $params Request parameters.
	 *
	 * @return array<int, string> Category names, ids or slugs, in order.
	 */
	private function collect_requested_categories( array $params ): array {
		$raw = null;

		foreach ( array( 'category', 'category_select', 'category_slug', 'category_id' ) as $key ) {
			if ( isset( $params[ $key ] ) && ( is_array( $params[ $key ] ) || is_string( $params[ $key ] ) || is_numeric( $params[ $key ] ) ) ) {
				if ( ! is_array( $params[ $key ] ) && '' === trim( (string) $params[ $key ] ) ) {
					continue;
				}

				$raw = $params[ $key ];
				break;
			}
		}

		if ( null === $raw ) {
			return array();
		}

		$candidates = is_array( $raw ) ? $raw : array( $raw );
		$collected  = array();

		foreach ( $candidates as $candidate ) {
			if ( is_array( $candidate ) || is_object( $candidate ) ) {
				continue;
			}

			$value = trim( sanitize_text_field( (string) $candidate ) );

			if ( '' !== $value && ! in_array( $value, $collected, true ) ) {
				$collected[] = $value;
			}
		}

		return $collected;
	}

	/**
	 * Find or create the category term for a name, slug or id.
	 *
	 * @param string $category Category name, slug or numeric term id.
	 *
	 * @return int Term ID, or 0 when the category should be skipped.
	 */
	private function resolve_category_term_id( string $category ): int {
		if ( ctype_digit( $category ) ) {
			$term = get_term( absint( $category ), 'category' );

			return ( $term && ! is_wp_error( $term ) ) ? (int) $term->term_id : 0;
		}

		$slug = sanitize_title( $category );

		if ( '' === $slug ) {
			return 0;
		}

		// Skip "uncategorized"; WordPress falls back to the site's default
		// category on its own, which may not be named "Uncategorized".
		if ( 'uncategorized' === $slug ) {
			return 0;
		}

		$term = get_term_by( 'slug', $slug, 'category' );

		if ( ! $term ) {
			// Match on the display name too, so "Web Design" reuses an existing
			// term whose slug happens to differ.
			$term = get_term_by( 'name', $category, 'category' );
		}

		if ( $term && ! is_wp_error( $term ) ) {
			return (int) $term->term_id;
		}

		/*
		 * Created with the name exactly as supplied. ucfirst() was applied here
		 * before, which mangled names that intentionally start lowercase
		 * ("iPhone Tips" became "IPhone Tips") and does nothing useful for
		 * multibyte names since it only touches the first byte.
		 */
		$insert = wp_insert_term( $category, 'category', array( 'slug' => $slug ) );

		if ( is_wp_error( $insert ) ) {
			/*
			 * A term_exists error still carries the id of the existing term, so
			 * a race between two concurrent callbacks does not lose the category.
			 */
			$existing_id = $insert->get_error_data( 'term_exists' );

			return is_numeric( $existing_id ) ? absint( $existing_id ) : 0;
		}

		return (int) $insert['term_id'];
	}

	/**
	 * Get the titles of the last N posts (published or draft).
	 *
	 * @param int $limit Number of post titles to retrieve.
	 *
	 * @return array Array of post titles
	 */
	private function get_last_post_titles( $limit = 150 ) {
		$post_titles = array();

		$args = array(
			'post_type'      => 'post',
			'post_status'    => array( 'publish', 'draft' ),
			'posts_per_page' => $limit,
			'orderby'        => 'date',
			'order'          => 'DESC',
			'fields'         => 'all',
		);

		$posts = get_posts( $args );

		if ( ! empty( $posts ) ) {
			foreach ( $posts as $post ) {
				$post_titles[] = $post->post_title;
			}
		}

		return $post_titles;
	}

	/**
	 * AJAX handler for generating posts.
	 *
	 * This method receives form data from the client and processes it
	 * to generate a post.
	 *
	 * @return void
	 */
	public function handle_ajax_generate_post() {
		// Get the raw POST data.

		// Check if we're receiving form data.
		$nonce = isset( $_POST['nonce'] ) ? sanitize_key( wp_unslash( $_POST['nonce'] ) ) : '';

		// Verify nonce before processing form data.
		if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'postomatic_admin_nonce' ) ) {
			wp_send_json_error( array( 'message' => 'Security verification failed.' ), 403 );
			return;
		}

		/*
		 * A valid nonce proves the request came from one of our pages, not that
		 * the user is allowed to create posts. Without this check any
		 * authenticated user who obtained a nonce could spend the site's
		 * credits on generating content.
		 */
		if ( ! current_user_can( 'edit_posts' ) ) {
			wp_send_json_error( array( 'message' => 'You do not have permission to create posts.' ), 403 );
			return;
		}

		$data = $_POST;

		// Convert string boolean values to actual booleans recursively.
		if ( is_array( $data ) ) {
			$data = $this->convert_string_booleans_to_actual( $data );
		}

		// Check if we have valid data.
		if ( empty( $data ) || ! is_array( $data ) ) {
			wp_send_json_error( array( 'message' => 'Invalid or empty request data' ), 400 );

			return;
		}

		// Call the generate_post method.
		$result = $this->generate_post( $data );

		// Handle the response.
		if ( ! $result['success'] ) {
			wp_send_json_error( $result['data'], $result['status'] );
		} else {
			wp_send_json_success( $result['data'] );
		}
		wp_die();
	}

	/**
	 * Recursively convert string boolean values to actual booleans.
	 *
	 * @param mixed $data The data to convert.
	 *
	 * @return mixed The converted data.
	 */
	private function convert_string_booleans_to_actual( $data ) {
		if ( is_array( $data ) ) {
			foreach ( $data as $key => $value ) {
				if ( is_array( $value ) || is_object( $value ) ) {
					// Recursively convert nested arrays and objects.
					$data[ $key ] = $this->convert_string_booleans_to_actual( $value );
				} elseif ( 'true' === $value ) {
					$data[ $key ] = true;
				} elseif ( 'false' === $value ) {
					$data[ $key ] = false;
				}
			}
		} elseif ( is_object( $data ) ) {
			foreach ( get_object_vars( $data ) as $key => $value ) {
				if ( is_array( $value ) || is_object( $value ) ) {
					// Recursively convert nested arrays and objects.
					$data->$key = $this->convert_string_booleans_to_actual( $value );
				} elseif ( 'true' === $value ) {
					$data->$key = true;
				} elseif ( 'false' === $value ) {
					$data->$key = false;
				}
			}
		}

		return $data;
	}

	/**
	 * Handle post generation via AJAX.
	 *
	 * This method receives form data, forwards it to the API_POST_MAKER endpoint,
	 * and returns the response.
	 *
	 * @param array $data The data for post generation.
	 *
	 * @return array The response from the API.
	 */
	public function generate_post( $data ) {
		// Check user authentication and that they may actually publish content.
		if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {
			return array(
				'success' => false,
				'data'    => array( 'message' => 'Unauthorized access.' ),
				'status'  => 403,
			);
		}

		// Verify nonce for security.
		if ( ! isset( $data['nonce'] ) || ! wp_verify_nonce( sanitize_key( $data['nonce'] ), 'postomatic_admin_nonce' ) ) {
			return array(
				'success' => false,
				'data'    => array( 'message' => 'Security verification failed.' ),
				'status'  => 403,
			);
		}

		// Get the API key.
		$api_key = Utils::get_api_service_key();
		if ( empty( $api_key ) ) {
			return array(
				'success' => false,
				'data'    => array( 'message' => 'API key not found' ),
				'status'  => 403,
			);
		}

		// Remove action and nonce from the payload.
		if ( isset( $data['action'] ) ) {
			unset( $data['action'] );
		}
		if ( isset( $data['nonce'] ) ) {
			unset( $data['nonce'] );
		}

		// Convert string boolean values to actual booleans recursively.
		$data = $this->convert_string_booleans_to_actual( $data );

		// Convert back to JSON for the API request.
		$json_data = wp_json_encode( $data );
		// Make the API request to the post maker endpoint.
		$api_url  = Constants::API_POST_MAKER;
		$response = wp_remote_post(
			$api_url,
			array(
				'headers'     => array(
					'Api-Key'      => $api_key,
					'Content-Type' => 'application/json',
					'Accept'       => 'application/json',
				),
				'body'        => $json_data,
				'timeout'     => 60, // Increase timeout for potentially long-running request.
				'data_format' => 'json',
			)
		);

		// Check for errors.
		if ( is_wp_error( $response ) ) {
			return array(
				'success' => false,
				'data'    => array( 'message' => $response->get_error_message() ),
				'status'  => 500,
			);
		}

		// Get the response status code.
		$status_code = wp_remote_retrieve_response_code( $response );

		/*
		 * Throttled, not unauthorised. Answer with the wait time so the UI can
		 * tell the user when to try again, and return before the 401 branch so a
		 * 429 never triggers a key rotation (which is itself rate limited).
		 */
		if ( 429 === (int) $status_code ) {
			$retry_after = Utils::get_retry_after_seconds( $response );

			return array(
				'success' => false,
				'data'    => array(
					'message'     => sprintf(
						/* translators: %d: seconds to wait. */
						esc_html__( 'Too many requests. Please try again in %d seconds.', 'postomatic' ),
						$retry_after
					),
					'retry_after' => $retry_after,
				),
				'status'  => 429,
			);
		}

		// Check for 401 Unauthorized error.
		if ( 401 === $status_code ) {
			/*
			 * Refresh the key and retry once, rather than only refreshing and
			 * returning the 401 as before. The old behaviour meant the first
			 * generation attempt after a key expiry always failed and the user
			 * had to press the button again for a request that would have
			 * succeeded.
			 */
			if ( Activator::refresh_api_key() ) {
				$retry = wp_remote_post(
					$api_url,
					array(
						'headers'     => array(
							'Api-Key'      => Utils::get_api_service_key(),
							'Content-Type' => 'application/json',
							'Accept'       => 'application/json',
						),
						'body'        => $json_data,
						'timeout'     => 60,
						'data_format' => 'json',
					)
				);

				if ( ! is_wp_error( $retry ) ) {
					$response    = $retry;
					$status_code = wp_remote_retrieve_response_code( $retry );
				}
			}
		}

		// Get the response body.
		$body          = wp_remote_retrieve_body( $response );
		$response_data = json_decode( $body, true );

		// Return the response with the same status code.
		if ( $status_code >= 400 ) {
			return array(
				'success' => false,
				'data'    => $response_data,
				'status'  => $status_code,
			);
		}

		return array(
			'success' => true,
			'data'    => $response_data,
			'status'  => $status_code,
		);
	}
}
