<?php
/**
 * Insert post REST endpoint for backend push generation
 *
 * Allows the premium backend to push generated posts directly to WordPress.
 * Authenticated via X-License-Key header matching the stored license key.
 *
 * @package Simple_Ai_Auto_Post
 * @since 2.1.0
 * @cache-bust 2026-06-25T14:35 (force opcache reload after detection-order fix)
 */

// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
	die;
}

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

/**
 * Register REST API route for inserting posts.
 */
function saiap_register_insert_post_route() {
	register_rest_route(
		'clearpost/v1',
		'/insert-post',
		array(
			'methods'             => 'POST',
			'callback'            => 'saiap_handle_insert_post',
			'permission_callback' => 'saiap_verify_insert_post_permission',
			'args'                => array(
				'title'      => array(
					'required'          => true,
					'type'              => 'string',
					'sanitize_callback' => 'sanitize_text_field',
				),
				'content'    => array(
					'required' => true,
					'type'     => 'string',
				),
				'status'     => array(
					'required' => false,
					'type'     => 'string',
					'default'  => 'draft',
					'enum'     => array( 'draft', 'publish', 'pending' ),
				),
				'categories' => array(
					'required' => false,
					'type'     => 'array',
					'default'  => array(),
					'items'    => array( 'type' => 'string' ),
				),
				'tags'       => array(
					'required' => false,
					'type'     => 'array',
					'default'  => array(),
					'items'    => array( 'type' => 'string' ),
				),
				'meta'       => array(
					'required' => false,
					'type'     => 'object',
					'default'  => array(),
				),
				'link'       => array(
					'required' => false,
					'type'     => 'string',
				),
				'link_title' => array(
					'required' => false,
					'type'     => 'string',
				),
				'session_id' => array(
					'required' => false,
					'type'     => 'string',
					'default'  => '',
				),
				'post_id'    => array(
					'required' => false,
					'type'     => 'integer',
				),
				'faq_schema' => array(
					'required' => false,
					'type'     => 'string',
					'default'  => '',
				),
				'excerpt'          => array(
					'required'          => false,
					'type'              => 'string',
					'default'           => '',
					'sanitize_callback' => 'sanitize_textarea_field',
				),
				'meta_description' => array(
					'required'          => false,
					'type'              => 'string',
					'default'           => '',
					'sanitize_callback' => 'sanitize_textarea_field',
				),
				'seo_title'        => array(
					'required'          => false,
					'type'              => 'string',
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				),
				'seoTitle'         => array(
					'required'          => false,
					'type'              => 'string',
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				),
				'focus_keyword'    => array(
					'required'          => false,
					'type'              => 'string',
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				),
			),
		)
	);
}
add_action( 'rest_api_init', 'saiap_register_insert_post_route' );

/**
 * Verify that the request has a valid license key.
 *
 * Uses the same authentication pattern as cron-trigger.php.
 *
 * @param WP_REST_Request $request The REST request.
 * @return bool|WP_Error True if valid, WP_Error otherwise.
 */
function saiap_verify_insert_post_permission( $request ) {
	$license_key_header = $request->get_header( 'X-License-Key' );

	if ( empty( $license_key_header ) ) {
		return new WP_Error(
			'missing_license_key',
			'X-License-Key header is required',
			array( 'status' => 401 )
		);
	}

	$stored_license_key = get_option( 'saiap_license_key', '' );

	if ( empty( $stored_license_key ) ) {
		// Self-heal: plugin was likely deleted and reinstalled (uninstall.php
		// wipes stored options). Validate the header key against the backend
		// and re-store it if it matches what the backend has on file.
		if ( saiap_try_recover_license_key( $license_key_header ) ) {
			return true;
		}
		return new WP_Error(
			'no_license_configured',
			'No license key configured on this site',
			array( 'status' => 403 )
		);
	}

	if ( ! hash_equals( $stored_license_key, $license_key_header ) ) {
		return new WP_Error(
			'invalid_license_key',
			'Invalid license key',
			array( 'status' => 403 )
		);
	}

	return true;
}

/**
 * Validate a license key against the backend and store it locally if valid.
 *
 * Used to recover from a missing stored key (e.g. after plugin
 * delete/reinstall). Only stores the key if the backend confirms it
 * matches the key on file for this domain.
 *
 * @param string $license_key The key from the X-License-Key header.
 * @return bool True if the key was validated and stored.
 */
function saiap_try_recover_license_key( $license_key ) {
	$domain = wp_parse_url( home_url(), PHP_URL_HOST );

	$response = wp_remote_post(
		SAIAP_PREMIUM_API_URL . '/api/trial/validate',
		array(
			'timeout' => 10,
			'body'    => wp_json_encode(
				array(
					'domain'      => $domain,
					'license_key' => $license_key,
				)
			),
			'headers' => array( 'Content-Type' => 'application/json' ),
		)
	);

	if ( is_wp_error( $response ) ) {
		return false;
	}

	$body = json_decode( wp_remote_retrieve_body( $response ), true );

	if ( empty( $body['isValid'] ) ) {
		return false;
	}

	$backend_key = isset( $body['trial']['license_key'] ) ? $body['trial']['license_key'] : '';
	if ( empty( $backend_key ) || ! hash_equals( $backend_key, $license_key ) ) {
		return false;
	}

	update_option( 'saiap_license_key', $license_key );
	return true;
}

/**
 * Handle the insert post request.
 *
 * Creates a WordPress post from backend-generated content.
 *
 * @param WP_REST_Request $request The REST request.
 * @return WP_REST_Response|WP_Error The response.
 */
function saiap_handle_insert_post( $request ) {
	$title            = $request->get_param( 'title' );
	$content          = $request->get_param( 'content' );
	$status           = $request->get_param( 'status' );
	$categories       = $request->get_param( 'categories' );
	$tags             = $request->get_param( 'tags' );
	$meta             = $request->get_param( 'meta' );
	$link             = $request->get_param( 'link' );
	$link_title       = $request->get_param( 'link_title' );
	$session_id       = $request->get_param( 'session_id' );
	$faq_schema       = $request->get_param( 'faq_schema' );
	$excerpt          = $request->get_param( 'excerpt' );
	$meta_description = $request->get_param( 'meta_description' );
	$seo_title        = $request->get_param( 'seo_title' );
	if ( '' === $seo_title || null === $seo_title ) {
		$seo_title = $request->get_param( 'seoTitle' );
	}
	$focus_keyword    = $request->get_param( 'focus_keyword' );

	// Dedup against the plugin pull path (FIX-PluginPullsContent). When backend
	// push and plugin pull race within the claim TTL window, both paths can try
	// to insert the same calendar item. The puller in includes/post-puller.php
	// stamps each pulled post with `_clearpost_calendar_item_id` post meta.
	// If we see a row with that meta already, return the existing post_id and
	// flag `already_delivered`. Backend treats it as success and adopts the
	// wp_post_id without creating a duplicate.
	$calendar_item_id = is_array( $meta ) && ! empty( $meta['_clearpost_calendar_item_id'] )
		? sanitize_text_field( $meta['_clearpost_calendar_item_id'] )
		: '';
	if ( ! empty( $calendar_item_id ) ) {
		$existing_pulled = get_posts(
			array(
				'meta_key'       => '_clearpost_calendar_item_id',
				'meta_value'     => $calendar_item_id,
				'post_type'      => 'post',
				'post_status'    => 'any',
				'posts_per_page' => 1,
				'fields'         => 'ids',
			)
		);
		if ( ! empty( $existing_pulled ) ) {
			$existing_id = (int) $existing_pulled[0];
			// Return image_agent_credentials even for already-delivered posts so the
			// cron can still run the image agent. Plugin pull often wins the push race
			// on active sites, and without credentials the image agent never fires.
			$already_delivered_credentials = null;
			if ( ! empty( $session_id ) && function_exists( 'saiap_generate_image_agent_hmac' ) ) {
				$hmac                          = saiap_generate_image_agent_hmac( $existing_id );
				$already_delivered_credentials = array(
					'wp_rest_url' => rest_url( 'simple-ai-auto-post/v1' ),
					'timestamp'   => $hmac['timestamp'],
					'token'       => $hmac['token'],
				);
			}
			return rest_ensure_response(
				array(
					'success'                 => true,
					'post_id'                 => $existing_id,
					'post_status'             => get_post_status( $existing_id ),
					'already_delivered'       => true,
					'edit_url'                => get_edit_post_link( $existing_id, '' ),
					'permalink'               => get_permalink( $existing_id ),
					'image_agent_credentials' => $already_delivered_credentials,
				)
			);
		}
	}

	// Respect review_before_publish setting.
	$review_first = get_option( 'saiap_review_before_publish', false );
	if ( $review_first && 'publish' === $status ) {
		$status = 'draft';
	}

	// Validate categories exist (don't auto-create).
	$category_ids = array();
	if ( ! empty( $categories ) ) {
		foreach ( $categories as $category_name ) {
			$term = get_term_by( 'name', $category_name, 'category' );
			if ( ! $term ) {
				// Try case-insensitive search.
				$term = saiap_get_term_by_name_case_insensitive( $category_name, 'category' );
			}
			if ( ! $term ) {
				return new WP_Error(
					'CATEGORY_NOT_FOUND',
					sprintf( "Category '%s' not found", $category_name ),
					array( 'status' => 400 )
				);
			}
			$category_ids[] = $term->term_id;
		}
	}

	// Validate tags exist or create them (tags are less strict).
	$tag_ids = array();
	if ( ! empty( $tags ) ) {
		foreach ( $tags as $tag_name ) {
			$term = get_term_by( 'name', $tag_name, 'post_tag' );
			if ( ! $term ) {
				// Try case-insensitive search.
				$term = saiap_get_term_by_name_case_insensitive( $tag_name, 'post_tag' );
			}
			if ( ! $term ) {
				// Create the tag if it doesn't exist.
				$result = wp_insert_term( $tag_name, 'post_tag' );
				if ( is_wp_error( $result ) ) {
					return new WP_Error(
						'TAG_CREATE_FAILED',
						sprintf( "Failed to create tag '%s': %s", $tag_name, $result->get_error_message() ),
						array( 'status' => 400 )
					);
				}
				$tag_ids[] = $result['term_id'];
			} else {
				$tag_ids[] = $term->term_id;
			}
		}
	}

	// Prepare post data.
	$post_data = array(
		'post_title'   => $title,
		'post_content' => $content,
		'post_status'  => $status,
		'post_type'    => 'post',
		'post_author'  => get_option( 'saiap_post_author', 1 ),
	);

	if ( ! empty( $excerpt ) ) {
		$post_data['post_excerpt'] = $excerpt;
	}

	// Set categories if provided.
	if ( ! empty( $category_ids ) ) {
		$post_data['post_category'] = $category_ids;
	}

	// If post_id provided, update existing post instead of inserting.
	$existing_post_id = $request->get_param( 'post_id' );
	if ( $existing_post_id ) {
		$existing = get_post( $existing_post_id );
		if ( ! $existing ) {
			return new WP_Error(
				'POST_NOT_FOUND',
				'Post not found',
				array( 'status' => 404 )
			);
		}
		$post_data['ID'] = $existing_post_id;
		unset( $post_data['post_author'] );
		unset( $post_data['post_type'] );
		$post_id = wp_update_post( $post_data, true );
	} else {
		$post_id = wp_insert_post( $post_data, true );
	}

	if ( is_wp_error( $post_id ) ) {
		return new WP_Error(
			'POST_INSERT_FAILED',
			'Failed to insert/update post: ' . $post_id->get_error_message(),
			array( 'status' => 500 )
		);
	}

	// Set tags if provided.
	if ( ! empty( $tag_ids ) ) {
		wp_set_post_tags( $post_id, $tag_ids, false );
	}

	// AI-based taxonomy assignment (categories/tags based on content analysis).
	// This matches the behavior of the old WordPress-initiated flow.
	$taxonomy_result = null;
	if ( function_exists( 'saiap_edit_post_taxonomy' ) ) {
		$taxonomy_res = saiap_edit_post_taxonomy( $post_id );
		if ( is_wp_error( $taxonomy_res ) ) {
			// Log but don't fail - taxonomy is best-effort.
			error_log( '[ClearPost] AI taxonomy assignment failed: ' . $taxonomy_res->get_error_message() );
			$taxonomy_result = array(
				'success' => false,
				'error'   => $taxonomy_res->get_error_message(),
			);
		} else {
			$taxonomy_result = array(
				'success' => true,
			);
		}
	}

	// Set post meta.
	if ( ! empty( $meta ) && is_array( $meta ) ) {
		foreach ( $meta as $key => $value ) {
			update_post_meta( $post_id, $key, $value );
		}
	}

	// Mark post as AI-generated (same as old flow).
	update_post_meta( $post_id, '_saiap_generated', true );

	// Store FAQ schema for JSON-LD output.
	if ( ! empty( $faq_schema ) ) {
		update_post_meta( $post_id, '_clearpost_faq_schema', $faq_schema );
	}

	// Map SEO fields into Yoast / RankMath meta keys, and set primary_category
	// based on the final assigned taxonomy. Must run AFTER saiap_edit_post_taxonomy
	// above so the category set is final.
	saiap_apply_seo_meta( $post_id, $meta_description, $focus_keyword, null, $seo_title );

	// Handle ACF link field if link provided.
	if ( ! empty( $link ) ) {
		$link_field_name = get_option( 'saiap_link_field_name', '' );
		if ( ! empty( $link_field_name ) && function_exists( 'update_field' ) ) {
			update_field( $link_field_name, $link, $post_id );
		}
		// Also store as post meta for non-ACF sites.
		update_post_meta( $post_id, '_clearpost_link', $link );
		if ( ! empty( $link_title ) ) {
			update_post_meta( $post_id, '_clearpost_link_title', $link_title );
		}
	}

	// Attach featured image.
	$image_result = null;
	if ( function_exists( 'saiap_attach_first_image_result' ) ) {
		$attach_res = saiap_attach_first_image_result( $post_id, $title );
		if ( is_int( $attach_res ) && $attach_res > 0 ) {
			do_action( 'saiap_auto_image_attached', $post_id, $attach_res );
			$image_result = array(
				'success'       => true,
				'attachment_id' => $attach_res,
			);
		} elseif ( is_wp_error( $attach_res ) ) {
			do_action( 'saiap_auto_image_failed', $post_id, $attach_res );
			$image_result = array(
				'success' => false,
				'error'   => $attach_res->get_error_message(),
			);
		}
	}

	// Return image agent credentials for cron to call directly.
	// The cron has the session in-memory, so it calls the image agent
	// directly instead of via WordPress triggering the backend.
	$image_agent_credentials = null;
	if ( ! empty( $session_id ) && function_exists( 'saiap_generate_image_agent_hmac' ) ) {
		$hmac                    = saiap_generate_image_agent_hmac( $post_id );
		$image_agent_credentials = array(
			'wp_rest_url' => rest_url( 'simple-ai-auto-post/v1' ),
			'timestamp'   => $hmac['timestamp'],
			'token'       => $hmac['token'],
		);
	}

	return rest_ensure_response(
		array(
			'success'                 => true,
			'post_id'                 => $post_id,
			'post_status'             => $status,
			'edit_url'                => get_edit_post_link( $post_id, '' ),
			'permalink'               => get_permalink( $post_id ),
			'image_result'            => $image_result,
			'taxonomy_result'         => $taxonomy_result,
			'image_agent_credentials' => $image_agent_credentials,
		)
	);
}

/**
 * Case-insensitive term search.
 *
 * @param string $name     Term name to search for.
 * @param string $taxonomy Taxonomy to search in.
 * @return WP_Term|false Term object or false if not found.
 */
function saiap_get_term_by_name_case_insensitive( $name, $taxonomy ) {
	global $wpdb;

	$term_id = $wpdb->get_var(
		$wpdb->prepare(
			"SELECT t.term_id FROM {$wpdb->terms} t
			INNER JOIN {$wpdb->term_taxonomy} tt ON t.term_id = tt.term_id
			WHERE LOWER(t.name) = LOWER(%s) AND tt.taxonomy = %s
			LIMIT 1",
			$name,
			$taxonomy
		)
	);

	if ( $term_id ) {
		return get_term( $term_id, $taxonomy );
	}

	return false;
}

/**
 * Output FAQ schema JSON-LD in the page head for posts that have it.
 */
function saiap_output_faq_schema() {
	if ( ! is_singular( 'post' ) ) {
		return;
	}

	$faq_schema = get_post_meta( get_the_ID(), '_clearpost_faq_schema', true );
	if ( empty( $faq_schema ) ) {
		return;
	}

	// Validate it's proper JSON before outputting.
	$decoded = json_decode( $faq_schema );
	if ( json_last_error() !== JSON_ERROR_NONE ) {
		return;
	}

	echo '<script type="application/ld+json">' . wp_json_encode( $decoded, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>' . "\n";
}
add_action( 'wp_head', 'saiap_output_faq_schema' );

/**
 * Detect the active SEO plugin on this site.
 *
 * Order matters: is_plugin_active is canonical and reflects WP's view of
 * what's actually live right now. We check it first because aggressive
 * opcache setups (e.g. Hostinger, some managed-WP hosts) can keep an SEO
 * plugin's WPSEO_VERSION / RANK_MATH_VERSION / AIOSEO_VERSION constant
 * defined for the PHP-FPM worker's lifetime even after the user deactivates
 * (or deletes) the plugin from wp-admin. is_plugin_active queries the
 * active_plugins option which updates immediately on deactivation, so it
 * sees the truth.
 *
 * Constants are kept as a fallback for the mu-plugin loader case where
 * the plugin loads outside the normal plugins.php lifecycle and
 * is_plugin_active returns false even though the plugin code is running.
 *
 * Verified 2026-06-25 against clearpostplugin.com on Hostinger: after
 * deleting Yoast, WPSEO_VERSION stayed defined across 10+ requests but
 * is_plugin_active('wordpress-seo/wp-seo.php') correctly returned false.
 *
 * @return string 'yoast' | 'rankmath' | 'aioseo' | 'none'
 */
function saiap_detect_active_seo_plugin() {
	if ( ! function_exists( 'is_plugin_active' ) ) {
		include_once ABSPATH . 'wp-admin/includes/plugin.php';
	}

	if (
		is_plugin_active( 'wordpress-seo/wp-seo.php' ) ||
		is_plugin_active( 'wordpress-seo-premium/wp-seo-premium.php' )
	) {
		return 'yoast';
	}

	if (
		is_plugin_active( 'seo-by-rank-math/rank-math.php' ) ||
		is_plugin_active( 'seo-by-rank-math-pro/rank-math-pro.php' )
	) {
		return 'rankmath';
	}

	if (
		is_plugin_active( 'all-in-one-seo-pack/all_in_one_seo_pack.php' ) ||
		is_plugin_active( 'all-in-one-seo-pack-pro/all_in_one_seo_pack.php' )
	) {
		return 'aioseo';
	}

	// mu-plugin / custom-loader fallback. is_plugin_active won't return true
	// for plugins loaded outside the regular plugins.php lifecycle, but their
	// version constants will still be defined.
	if ( defined( 'WPSEO_VERSION' ) ) {
		return 'yoast';
	}
	if ( defined( 'RANK_MATH_VERSION' ) ) {
		return 'rankmath';
	}
	if ( defined( 'AIOSEO_VERSION' ) || defined( 'AIOSEO_FILE' ) ) {
		return 'aioseo';
	}

	return 'none';
}

/**
 * Pick the primary category for a post.
 *
 * Strategy: first non-Uncategorized category currently assigned. Falls back to
 * the first category if all assigned are Uncategorized.
 *
 * @param int $post_id Post ID.
 * @return int Category term_id, or 0 if no categories assigned.
 */
function saiap_pick_primary_category_id( $post_id ) {
	// Force 'fields' => 'ids' so we get int[]. Without it the stub is
	// WP_Term[]|int[] which PHPStan rightly refuses to int-cast.
	$cat_ids = wp_get_post_categories( $post_id, array( 'fields' => 'ids' ) );
	if ( empty( $cat_ids ) || ! is_array( $cat_ids ) ) {
		return 0;
	}
	$non_default = array_values(
		array_filter(
			$cat_ids,
			function ( $id ) {
				return (int) $id !== 1; // term_id=1 is the default Uncategorized.
			}
		)
	);
	if ( ! empty( $non_default ) ) {
		return (int) $non_default[0];
	}
	return (int) $cat_ids[0];
}

/**
 * Measure a UTF-8 string for SEO limits.
 *
 * @param string $text Text to measure.
 * @return int
 */
function saiap_seo_strlen( $text ) {
	return function_exists( 'mb_strlen' ) ? mb_strlen( $text, 'UTF-8' ) : strlen( $text );
}

/**
 * Slice a UTF-8 string for SEO limits.
 *
 * @param string $text   Text to slice.
 * @param int    $start  Start offset.
 * @param int    $length Length.
 * @return string
 */
function saiap_seo_substr( $text, $start, $length ) {
	return function_exists( 'mb_substr' ) ? mb_substr( $text, $start, $length, 'UTF-8' ) : substr( $text, $start, $length );
}

/**
 * Find the last space in a UTF-8 string.
 *
 * @param string $text Text to inspect.
 * @return int|false
 */
function saiap_seo_strrpos_space( $text ) {
	return function_exists( 'mb_strrpos' ) ? mb_strrpos( $text, ' ', 0, 'UTF-8' ) : strrpos( $text, ' ' );
}

/**
 * Remove formulaic ClearPost-first openers from generated SEO text.
 *
 * @param string $text Raw text.
 * @return string
 */
function saiap_remove_banned_clearpost_opener( $text ) {
	$original = (string) $text;
	$text     = preg_replace( '/^(?:at|here at)\s+ClearPost\s*,?\s+(?:we\s+|we(?:\'|’)?re\s+|we\s+are\s+)/iu', '', $original );
	if ( null === $text || $text === $original ) {
		return $original;
	}

	$text = trim( $text );
	if ( '' === $text ) {
		return $text;
	}

	$first = saiap_seo_substr( $text, 0, 1 );
	$rest  = saiap_seo_substr( $text, 1, max( 0, saiap_seo_strlen( $text ) - 1 ) );
	$first = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $first, 'UTF-8' ) : strtoupper( $first );

	return $first . $rest;
}

/**
 * Normalize plain SEO text and enforce a hard character ceiling.
 *
 * @param string $text       Raw SEO text.
 * @param int    $max_length Maximum character length.
 * @return string
 */
function saiap_prepare_limited_seo_text( $text, $max_length ) {
	$text = wp_strip_all_tags( (string) $text );
	$text = html_entity_decode( $text, ENT_QUOTES, get_bloginfo( 'charset' ) ?: 'UTF-8' );
	$text = trim( preg_replace( '/\s+/u', ' ', $text ) );
	$text = saiap_remove_banned_clearpost_opener( $text );

	if ( '' === $text || saiap_seo_strlen( $text ) <= $max_length ) {
		return $text;
	}

	$ellipsis  = function_exists( 'mb_strlen' ) ? '…' : '...';
	$cut_limit = max( 1, $max_length - saiap_seo_strlen( $ellipsis ) );
	$cut       = saiap_seo_substr( $text, 0, $cut_limit );
	$last_space = saiap_seo_strrpos_space( $cut );

	// Prefer a word boundary unless it would remove too much useful copy.
	if ( false !== $last_space && $last_space >= (int) floor( $cut_limit * 0.6 ) ) {
		$cut = saiap_seo_substr( $cut, 0, $last_space );
	}

	$cut = trim( $cut, " \t\n\r\0\x0B.,;:" );

	return saiap_seo_substr( $cut . $ellipsis, 0, $max_length );
}

/**
 * Strip site-name suffixes from SEO titles.
 *
 * @param string $title Raw title.
 * @return string
 */
function saiap_strip_clearpost_title_suffix( $title ) {
	$title = trim( preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $title ) ) );
	$title = preg_replace( '/\s*(?:-|\||–|—)\s*ClearPost(?:\s+Plugin)?\s*$/iu', '', $title );
	return trim( $title );
}

/**
 * Prepare a per-post SEO title. Empty input falls back to the post title.
 *
 * @param int    $post_id   Post ID.
 * @param string $seo_title Optional SEO title.
 * @return string
 */
function saiap_prepare_seo_title( $post_id, $seo_title = '' ) {
	$source = trim( (string) $seo_title );
	if ( '' === $source ) {
		$source = get_the_title( $post_id );
	}

	$source = saiap_strip_clearpost_title_suffix( $source );

	return saiap_prepare_limited_seo_text( $source, 60 );
}

/**
 * Identify posts whose AIOSEO output ClearPost should normalize.
 *
 * @param int $post_id Post ID.
 * @return bool
 */
function saiap_should_limit_aioseo_output_for_post( $post_id ) {
	$post_id = (int) $post_id;
	if ( $post_id <= 0 || 'post' !== get_post_type( $post_id ) ) {
		return false;
	}

	return (bool) get_post_meta( $post_id, '_saiap_generated', true ) ||
		(bool) get_post_meta( $post_id, '_clearpost_calendar_item_id', true ) ||
		(bool) get_post_meta( $post_id, '_clearpost_meta_description', true ) ||
		(bool) get_post_meta( $post_id, '_clearpost_seo_title', true );
}

/**
 * Enforce ClearPost's SERP title ceiling on AIOSEO-rendered output.
 *
 * This catches existing posts that still have long AIOSEO template output while
 * saiap_apply_seo_meta() keeps future writes normalized at the database layer.
 *
 * @param string $title Rendered AIOSEO title.
 * @return string
 */
function saiap_limit_aioseo_rendered_title( $title ) {
	$post_id = function_exists( 'get_queried_object_id' ) ? (int) get_queried_object_id() : 0;
	if ( ! $post_id || ! saiap_should_limit_aioseo_output_for_post( $post_id ) ) {
		return $title;
	}

	$title = saiap_strip_clearpost_title_suffix( $title );

	return saiap_prepare_limited_seo_text( $title, 60 );
}
add_filter( 'aioseo_title', 'saiap_limit_aioseo_rendered_title', 20 );

/**
 * Enforce ClearPost's SERP description ceiling on AIOSEO-rendered output.
 *
 * @param string $description Rendered AIOSEO meta description.
 * @return string
 */
function saiap_limit_aioseo_rendered_description( $description ) {
	$post_id = function_exists( 'get_queried_object_id' ) ? (int) get_queried_object_id() : 0;
	if ( ! $post_id || ! saiap_should_limit_aioseo_output_for_post( $post_id ) ) {
		return $description;
	}

	return saiap_prepare_limited_seo_text( $description, 155 );
}
add_filter( 'aioseo_description', 'saiap_limit_aioseo_rendered_description', 20 );

/**
 * Fill Yoast / RankMath / AIOSEO SEO meta keys from backend-generated SEO copy, plus the
 * primary category for posts in multiple categories.
 *
 * Always writes durable backup keys (_clearpost_meta_description,
 * _clearpost_focus_keyword, _clearpost_seo_title) so the data isn't lost if
 * the customer switches SEO plugins later.
 *
 * Call AFTER any taxonomy reassignment so the category set is final.
 *
 * @param int      $post_id             Post ID.
 * @param string   $meta_description    Meta description copy.
 * @param string     $focus_keyword       Primary SEO keyword.
 * @param int|null   $primary_category_id Optional explicit primary category term_id.
 *                                      If null, auto-picks first non-Uncategorized.
 *                                      If 0, skips primary category write entirely.
 * @param string|false $seo_title           Optional SEO title. False skips title writes;
 *                                          empty string derives from the post title.
 */
function saiap_apply_seo_meta( $post_id, $meta_description, $focus_keyword, $primary_category_id = null, $seo_title = false ) {
	$meta_description      = saiap_prepare_limited_seo_text( $meta_description, 155 );
	$should_write_seo_title = ( false !== $seo_title );
	$seo_title             = $should_write_seo_title ? saiap_prepare_seo_title( $post_id, $seo_title ) : '';

	if ( ! empty( $meta_description ) ) {
		update_post_meta( $post_id, '_clearpost_meta_description', $meta_description );
	}
	if ( $should_write_seo_title && ! empty( $seo_title ) ) {
		update_post_meta( $post_id, '_clearpost_seo_title', $seo_title );
	}
	if ( ! empty( $focus_keyword ) ) {
		update_post_meta( $post_id, '_clearpost_focus_keyword', $focus_keyword );
	}

	if ( null === $primary_category_id ) {
		$primary_category_id = saiap_pick_primary_category_id( $post_id );
	}
	$seo_plugin = saiap_detect_active_seo_plugin();

	switch ( $seo_plugin ) {
		case 'yoast':
			if ( ! empty( $meta_description ) ) {
				update_post_meta( $post_id, '_yoast_wpseo_metadesc', $meta_description );
			}
			if ( $should_write_seo_title && ! empty( $seo_title ) ) {
				update_post_meta( $post_id, '_yoast_wpseo_title', $seo_title );
			}
			if ( ! empty( $focus_keyword ) ) {
				update_post_meta( $post_id, '_yoast_wpseo_focuskw', $focus_keyword );
			}
			if ( $primary_category_id > 0 ) {
				update_post_meta( $post_id, '_yoast_wpseo_primary_category', $primary_category_id );
			}
			break;

		case 'rankmath':
			if ( ! empty( $meta_description ) ) {
				update_post_meta( $post_id, 'rank_math_description', $meta_description );
			}
			if ( $should_write_seo_title && ! empty( $seo_title ) ) {
				update_post_meta( $post_id, 'rank_math_title', $seo_title );
			}
			if ( ! empty( $focus_keyword ) ) {
				update_post_meta( $post_id, 'rank_math_focus_keyword', $focus_keyword );
			}
			if ( $primary_category_id > 0 ) {
				update_post_meta( $post_id, 'rank_math_primary_category', $primary_category_id );
			}
			break;

		case 'aioseo':
			// AIOSEO v4+ stores SEO data in the wp_aioseo_posts custom table, not
			// postmeta. Verified against the AIOSEO source on 2026-06-18:
			//   - Models\Post::getPost($post_id) returns the model (creates new if
			//     row doesn't exist; sets post_id on the new model).
			//   - description is a plain string column.
			//   - keyphrases / primary_term are JSON-encoded columns; AIOSEO's
			//     own REST controller assigns wp_json_encode(...) directly to the
			//     property (see Common/Api/PostsTerms.php).
			//   - keyphrases shape: { focus: { keyphrase: "..." }, additional: [] }
			//     The model fills in additional defaults via getKeyphrasesDefaults
			//     when read, so we only need to set focus.keyphrase.
			//   - primary_term shape: { tax_slug: term_id }, e.g. { category: 5 }
			//     Confirmed by AIOSEO's own RankMath-import mapping
			//     (rank_math_primary_category → primary_term).
			//   - save() inserts on new model, updates on existing — both paths
			//     in Common/Models/Model.php::save().
			//
			// Defensive class_exists + try/catch so a future AIOSEO breaking
			// change degrades to the durable _clearpost_* backup keys (written
			// above) rather than 500ing the insert-post endpoint.
			if ( class_exists( '\\AIOSEO\\Plugin\\Common\\Models\\Post' ) ) {
				try {
					$aioseo_post = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
					if ( $aioseo_post ) {
						$dirty = false;
						if ( ! empty( $meta_description ) ) {
							$aioseo_post->description = $meta_description;
							$dirty                    = true;
						}
						if ( $should_write_seo_title && ! empty( $seo_title ) ) {
							$aioseo_post->title = $seo_title;
							$dirty             = true;
						}
						if ( ! empty( $focus_keyword ) ) {
							$aioseo_post->keyphrases = wp_json_encode(
								array(
									'focus'      => array( 'keyphrase' => $focus_keyword ),
									'additional' => array(),
								)
							);
							$dirty = true;
						}
						if ( $primary_category_id > 0 ) {
							$aioseo_post->primary_term = wp_json_encode(
								array( 'category' => $primary_category_id )
							);
							$dirty = true;
						}
						if ( $dirty ) {
							$aioseo_post->save();
						}
					}
				} catch ( \Throwable $e ) {
					error_log( '[ClearPost] AIOSEO model write failed: ' . $e->getMessage() );
				}
			}
			break;

		default:
			// No SEO plugin detected — the durable backup keys above are enough.
			break;
	}
}
