<?php
/**
 * REST API for reading and updating SEO meta on existing posts.
 *
 * Powers three consumer surfaces from a single write path:
 *  - The plugin's MCP abilities (`clearpost_get_seo_meta`, `clearpost_update_seo_meta`)
 *  - The backend's Clarence tool (calls these endpoints with the site's license key)
 *  - Any external agent / VPS bot via direct HTTP
 *
 * Both endpoints delegate to `saiap_apply_seo_meta()` in api-insert-post.php so
 * the same dispatcher (Yoast / Rank Math / AIOSEO / none) backs every write.
 *
 * Auth: X-License-Key header (same as /insert-post) plus an `edit_post`
 * capability check against the resolved WP user.
 *
 * @package Simple_Ai_Auto_Post
 * @since 2.0.55
 */

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

/**
 * Register the GET + POST routes.
 */
function saiap_register_seo_meta_routes() {
	register_rest_route(
		'clearpost/v1',
		'/seo-meta',
		array(
			array(
				'methods'             => 'GET',
				'callback'            => 'saiap_handle_get_seo_meta',
				'permission_callback' => 'saiap_verify_seo_meta_permission',
				'args'                => array(
					'post_id' => array(
						'required' => true,
						'type'     => 'integer',
					),
				),
			),
			array(
				'methods'             => 'POST',
				'callback'            => 'saiap_handle_update_seo_meta',
				'permission_callback' => 'saiap_verify_seo_meta_permission',
				'args'                => array(
					'post_id'             => array(
						'required' => true,
						'type'     => 'integer',
					),
					'meta_description'    => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'seo_title'           => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'focus_keyword'       => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'excerpt'             => array(
						'required'          => false,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'primary_category_id' => array(
						'required' => false,
						'type'     => 'integer',
					),
				),
			),
		)
	);
}
add_action( 'rest_api_init', 'saiap_register_seo_meta_routes' );

/**
 * License-key permission check. Same shape as /insert-post.
 *
 * Per-post capability is checked inside the handler since it needs the
 * resolved post_id, which isn't available here.
 *
 * @param WP_REST_Request $request The REST request.
 * @return bool|WP_Error
 */
function saiap_verify_seo_meta_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 ) ) {
		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;
}

/**
 * GET /clearpost/v1/seo-meta?post_id=<id>
 *
 * Returns the current SEO state for the post — what's actually written to
 * the active SEO plugin's keys, plus our durable backup keys, plus the
 * assigned categories so the caller can pick a primary.
 *
 * @param WP_REST_Request $request The REST request.
 * @return WP_REST_Response|WP_Error
 */
function saiap_handle_get_seo_meta( $request ) {
	$post_id = (int) $request->get_param( 'post_id' );
	$post    = get_post( $post_id );
	if ( ! $post ) {
		return new WP_Error(
			'POST_NOT_FOUND',
			'Post not found',
			array( 'status' => 404 )
		);
	}

	return rest_ensure_response( saiap_read_seo_meta( $post_id ) );
}

/**
 * POST /clearpost/v1/seo-meta
 *
 * Partial update — only fields you pass are written. Returns the resulting
 * state so the caller can show a diff.
 *
 * @param WP_REST_Request $request The REST request.
 * @return WP_REST_Response|WP_Error
 */
function saiap_handle_update_seo_meta( $request ) {
	$post_id = (int) $request->get_param( 'post_id' );
	$post    = get_post( $post_id );
	if ( ! $post ) {
		return new WP_Error(
			'POST_NOT_FOUND',
			'Post not found',
			array( 'status' => 404 )
		);
	}

	// Treat the license-key holder as the post author for cap checks. This is
	// the same assumption /insert-post makes when it creates posts. The caller
	// must already control the site's license key, which is the same surface
	// as wp-admin access from a security standpoint.
	$author_id = (int) get_option( 'saiap_post_author', 1 );
	if ( ! user_can( $author_id, 'edit_post', $post_id ) ) {
		return new WP_Error(
			'cannot_edit_post',
			'Configured post author lacks edit_post capability for this post',
			array( 'status' => 403 )
		);
	}

	$meta_description    = $request->get_param( 'meta_description' );
	$seo_title           = $request->get_param( 'seo_title' );
	$focus_keyword       = $request->get_param( 'focus_keyword' );
	$excerpt             = $request->get_param( 'excerpt' );
	$primary_category_id = $request->get_param( 'primary_category_id' );

	$updated = array();

	if ( null !== $excerpt && '' !== $excerpt ) {
		wp_update_post(
			array(
				'ID'           => $post_id,
				'post_excerpt' => $excerpt,
			)
		);
		$updated[] = 'excerpt';
	}

	// If primary_category_id is provided, verify it's actually assigned to the
	// post before writing. Silently dropping invalid term IDs is safer than
	// 500ing the request — the agent should see the GET diff and notice.
	$primary_for_dispatcher = null; // null = let dispatcher auto-pick
	if ( null !== $primary_category_id ) {
		$pcid = (int) $primary_category_id;
		if ( $pcid > 0 ) {
			$post_cat_ids = wp_get_post_categories( $post_id, array( 'fields' => 'ids' ) );
			if ( is_array( $post_cat_ids ) && in_array( $pcid, array_map( 'intval', $post_cat_ids ), true ) ) {
				$primary_for_dispatcher = $pcid;
				$updated[]              = 'primary_category';
			}
		} else {
			// Explicit 0 means "skip primary category entirely on this write."
			$primary_for_dispatcher = 0;
		}
	}

	if ( ( null !== $meta_description && '' !== $meta_description ) ||
		( null !== $seo_title && '' !== $seo_title ) ||
		( null !== $focus_keyword && '' !== $focus_keyword ) ||
		null !== $primary_for_dispatcher ) {
		// Dispatcher expects strings, not nulls.
		saiap_apply_seo_meta(
			$post_id,
			(string) ( $meta_description ?? '' ),
			(string) ( $focus_keyword ?? '' ),
			$primary_for_dispatcher,
			null !== $seo_title ? (string) $seo_title : false
		);
		if ( null !== $meta_description && '' !== $meta_description ) {
			$updated[] = 'meta_description';
		}
		if ( null !== $seo_title && '' !== $seo_title ) {
			$updated[] = 'seo_title';
		}
		if ( null !== $focus_keyword && '' !== $focus_keyword ) {
			$updated[] = 'focus_keyword';
		}
	}

	$response          = saiap_read_seo_meta( $post_id );
	$response['updated_fields'] = $updated;
	return rest_ensure_response( $response );
}

/**
 * Read a single postmeta value via direct DB query, bypassing the
 * get_post_metadata filter chain entirely.
 *
 * Why not get_post_meta(): Yoast and Rank Math both hook get_post_metadata
 * to serve values from their own indexable / cache layer. Right after a
 * fresh update_post_meta() write — before their indexable rebuilds — that
 * filter can return empty even though the underlying postmeta IS correct
 * (confirmed via WP REST direct read and the SEO plugin's own meta box).
 *
 * That was an "agent UX bug" during the 2026-06-25 Yoast smoke: read →
 * propose → write → re-read returned empty, so an agent following the
 * tool's documented "read, propose, then write" loop would have assumed
 * the write failed and re-issued it.
 *
 * Direct $wpdb avoids the filter, works on any WP version, and the result
 * is exactly what's in postmeta. The plugin-check noise (DirectDatabaseQuery
 * / NoCaching) is the standard pattern for filter-bypassing reads.
 *
 * @param int    $post_id Post ID.
 * @param string $key     Meta key.
 * @return string
 */
function saiap_read_postmeta_raw( $post_id, $key ) {
	global $wpdb;
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
	$value = $wpdb->get_var(
		$wpdb->prepare(
			"SELECT meta_value FROM {$wpdb->postmeta} WHERE post_id = %d AND meta_key = %s LIMIT 1",
			(int) $post_id,
			(string) $key
		)
	);
	return null === $value ? '' : (string) $value;
}

/**
 * Shared read function. Used by GET and by POST's after-write diff response.
 *
 * @param int $post_id Post ID.
 * @return array
 */
function saiap_read_seo_meta( $post_id ) {
	$post       = get_post( $post_id );
	$seo_plugin = saiap_detect_active_seo_plugin();

	$meta_description = '';
	$focus_keyword    = '';
	$seo_title        = '';

	switch ( $seo_plugin ) {
		case 'yoast':
			$meta_description = saiap_read_postmeta_raw( $post_id, '_yoast_wpseo_metadesc' );
			$focus_keyword    = saiap_read_postmeta_raw( $post_id, '_yoast_wpseo_focuskw' );
			$seo_title        = saiap_read_postmeta_raw( $post_id, '_yoast_wpseo_title' );
			break;
		case 'rankmath':
			$meta_description = saiap_read_postmeta_raw( $post_id, 'rank_math_description' );
			$focus_keyword    = saiap_read_postmeta_raw( $post_id, 'rank_math_focus_keyword' );
			$seo_title        = saiap_read_postmeta_raw( $post_id, 'rank_math_title' );
			break;
		case 'aioseo':
			if ( class_exists( '\\AIOSEO\\Plugin\\Common\\Models\\Post' ) ) {
				try {
					$aioseo_post = \AIOSEO\Plugin\Common\Models\Post::getPost( $post_id );
					if ( $aioseo_post ) {
						$meta_description = isset( $aioseo_post->description ) ? (string) $aioseo_post->description : '';
						$seo_title        = isset( $aioseo_post->title ) ? (string) $aioseo_post->title : '';
						if ( ! empty( $aioseo_post->keyphrases ) ) {
							$kp = is_string( $aioseo_post->keyphrases )
								? json_decode( $aioseo_post->keyphrases )
								: $aioseo_post->keyphrases;
							if ( $kp && isset( $kp->focus ) && isset( $kp->focus->keyphrase ) ) {
								$focus_keyword = (string) $kp->focus->keyphrase;
							}
						}
					}
				} catch ( \Throwable $e ) {
					// Fall through to backup keys below.
					error_log( '[ClearPost] AIOSEO read failed: ' . $e->getMessage() );
				}
			}
			break;
	}

	if ( empty( $seo_title ) ) {
		$seo_title = get_post_meta( $post_id, '_clearpost_seo_title', true );
	}

	// Find current primary category.
	$primary_category_id   = saiap_pick_primary_category_id( $post_id );
	$primary_category_name = '';
	if ( $primary_category_id > 0 ) {
		$term = get_term( $primary_category_id, 'category' );
		if ( $term && ! is_wp_error( $term ) ) {
			$primary_category_name = (string) $term->name;
		}
	}

	$category_terms = get_the_terms( $post_id, 'category' );
	$categories     = array();
	if ( is_array( $category_terms ) ) {
		foreach ( $category_terms as $term ) {
			$categories[] = array(
				'id'   => (int) $term->term_id,
				'name' => (string) $term->name,
			);
		}
	}

	return array(
		'post_id'                     => (int) $post_id,
		'post_title'                  => (string) $post->post_title,
		'post_excerpt'                => (string) $post->post_excerpt,
		'seo_plugin'                  => $seo_plugin,
		'meta_description'            => $meta_description,
		'focus_keyword'               => $focus_keyword,
		'seo_title'                   => $seo_title,
		'primary_category_id'         => (int) $primary_category_id,
		'primary_category_name'       => $primary_category_name,
		'categories'                  => $categories,
		'_clearpost_meta_description' => (string) get_post_meta( $post_id, '_clearpost_meta_description', true ),
		'_clearpost_seo_title'        => (string) get_post_meta( $post_id, '_clearpost_seo_title', true ),
		'_clearpost_focus_keyword'    => (string) get_post_meta( $post_id, '_clearpost_focus_keyword', true ),
	);
}
