<?php
/**
 * REST API Router
 *
 * Ported from the original SEOvault AI connector to preserve
 * backward compatibility with the web app. Uses the seovaultai/v1
 * namespace as required by the spec.
 *
 * @package SEOvaultSEO\Api
 */

namespace SEOvaultSEO\Api;

use SEOvaultSEO\Core\Options;
use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Compatibility\Detector;
use SEOvaultSEO\Utils\Logger;
use SEOvaultSEO\Utils\Paths;

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

class Router {

	const NAMESPACE = 'seovaultai/v1';

	/**
	 * HTTP methods treated as "write" operations for audit logging
	 * (SEC-007). The plugin only registers `POST` routes today; `PUT`/
	 * `PATCH`/`DELETE` are included so future routes are covered without
	 * further changes here.
	 */
	const WRITE_METHODS = array( 'POST', 'PUT', 'PATCH', 'DELETE' );

	public static function init() {
		RateLimiter::init();
		add_action( 'rest_api_init', array( __CLASS__, 'setup_cors' ), 15 );
		add_filter( 'rest_authentication_errors', array( __CLASS__, 'bypass_rest_restrictions' ), 999 );
		add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
		add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'log_write_success' ), 10, 3 );
		add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'log_read_success' ), 10, 3 );
	}

	// -----------------------------------------------------------------------
	// CORS (Program E — allowlist via CorsPolicy)
	// -----------------------------------------------------------------------

	public static function setup_cors() {
		remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
		add_filter( 'rest_pre_dispatch', array( __CLASS__, 'handle_cors_preflight' ), 10, 3 );
		add_filter( 'rest_pre_serve_request', array( __CLASS__, 'send_cors_headers' ), 10, 3 );
	}

	/**
	 * Read the browser Origin for CORS decisions.
	 * Prefers the REST request header (testable) then HTTP_ORIGIN.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return string Normalized origin or empty string.
	 */
	public static function get_request_origin( \WP_REST_Request $request ) {
		$header = $request->get_header( 'Origin' );
		if ( is_string( $header ) && '' !== $header ) {
			return CorsPolicy::normalize_origin( $header );
		}

		$server = self::get_server_var( 'HTTP_ORIGIN' );
		return CorsPolicy::normalize_origin( $server );
	}

	/**
	 * Build the CORS response headers for an allowed origin.
	 * Credentials are intentionally omitted (not required for site-key auth).
	 *
	 * @param string $origin Allowed, normalized origin.
	 * @return array<string, string>
	 */
	public static function build_cors_headers( $origin ) {
		return array(
			'Access-Control-Allow-Origin'  => (string) $origin,
			'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
			'Access-Control-Allow-Headers' => 'Authorization, Content-Type, X-Site-Key',
			'Access-Control-Max-Age'       => '3600',
			'Vary'                         => 'Origin',
		);
	}

	/**
	 * Evaluate CORS for a namespace request (testable; no header I/O).
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return array{
	 *   origin: string,
	 *   has_origin: bool,
	 *   allowed: bool,
	 *   emit_headers: bool,
	 *   options_status: int|null,
	 *   headers: array<string, string>,
	 *   error: \WP_Error|null
	 * }
	 */
	public static function evaluate_cors( \WP_REST_Request $request ) {
		$origin     = self::get_request_origin( $request );
		$has_origin = '' !== $origin;
		$is_options = 'OPTIONS' === strtoupper( $request->get_method() );

		// Server-to-server (no Origin): do not emit CORS headers.
		if ( ! $has_origin ) {
			return array(
				'origin'         => '',
				'has_origin'     => false,
				'allowed'        => false,
				'emit_headers'   => false,
				'options_status' => $is_options ? 200 : null,
				'headers'        => array(),
				'error'          => null,
			);
		}

		$allowed = CorsPolicy::is_allowed_origin( $origin );
		if ( ! $allowed ) {
			$error = null;
			if ( $is_options ) {
				$error = new \WP_Error(
					'seovaultai_cors_origin_denied',
					__( 'Origin is not allowed by CORS policy.', 'seovault-ai' ),
					array( 'status' => 403 )
				);
			}

			return array(
				'origin'         => $origin,
				'has_origin'     => true,
				'allowed'        => false,
				'emit_headers'   => false,
				'options_status' => $is_options ? 403 : null,
				'headers'        => array(),
				'error'          => $error,
			);
		}

		return array(
			'origin'         => $origin,
			'has_origin'     => true,
			'allowed'        => true,
			'emit_headers'   => true,
			'options_status' => $is_options ? 200 : null,
			'headers'        => self::build_cors_headers( $origin ),
			'error'          => null,
		);
	}

	/**
	 * OPTIONS preflight: reject disallowed Origin with 403 before route
	 * handlers run (SEC-037). Allowed / no-Origin OPTIONS continue.
	 *
	 * @param mixed            $result  Dispatch result so far.
	 * @param \WP_REST_Server  $server  Server.
	 * @param \WP_REST_Request $request Request.
	 * @return mixed
	 */
	public static function handle_cors_preflight( $result, $server, \WP_REST_Request $request ) {
		unset( $server );

		if ( ! self::is_namespace_request( $request ) ) {
			return $result;
		}

		if ( 'OPTIONS' !== strtoupper( $request->get_method() ) ) {
			return $result;
		}

		$decision = self::evaluate_cors( $request );
		if ( $decision['error'] instanceof \WP_Error ) {
			return $decision['error'];
		}

		return $result;
	}

	/**
	 * Emit allowlisted CORS headers on namespace responses (SEC-036/038).
	 * No Origin → no CORS headers. Disallowed Origin → omit ACAO.
	 *
	 * @param bool             $served  Whether the request has already been served.
	 * @param mixed            $result  Response to serve.
	 * @param \WP_REST_Request $request Request.
	 * @return bool
	 */
	public static function send_cors_headers( $served, $result, \WP_REST_Request $request ) {
		if ( ! self::is_namespace_request( $request ) ) {
			// setup_cors() replaces WordPress core's global callback so the
			// connector can enforce its own allowlist. Delegate every other
			// namespace back to core instead of removing CORS site-wide.
			return \rest_send_cors_headers( $served, $result, $request );
		}

		unset( $result );

		$decision = self::evaluate_cors( $request );

		if ( $decision['emit_headers'] ) {
			foreach ( $decision['headers'] as $name => $value ) {
				header( $name . ': ' . $value );
			}
		}

		if ( 'OPTIONS' === strtoupper( $request->get_method() ) ) {
			$status = null !== $decision['options_status'] ? (int) $decision['options_status'] : 200;
			status_header( $status );
			if ( 403 === $status && $decision['error'] instanceof \WP_Error ) {
				header( 'Content-Type: application/json; charset=utf-8' );
				echo wp_json_encode(
					array(
						'code'    => $decision['error']->get_error_code(),
						'message' => $decision['error']->get_error_message(),
						'data'    => $decision['error']->get_error_data(),
					)
				);
			}
			exit;
		}

		return $served;
	}

	// -----------------------------------------------------------------------
	// Auth bypass
	// -----------------------------------------------------------------------

	public static function bypass_rest_restrictions( $result ) {
		if ( ! is_wp_error( $result ) ) {
			return $result;
		}

		if ( self::is_namespace_request() ) {
			return true;
		}

		return $result;
	}

	/**
	 * Read and sanitize a string server variable used for request routing.
	 *
	 * @param string $key Server variable name.
	 * @return string Sanitized value, or an empty string when unavailable.
	 */
	public static function get_server_var( $key ) {
		$key = strtoupper( preg_replace( '/[^A-Z0-9_]/i', '', (string) $key ) );
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only request-routing value; sanitized before return.
		if ( '' === $key || ! isset( $_SERVER[ $key ] ) || ! is_string( $_SERVER[ $key ] ) ) {
			return '';
		}
		$value = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		return $value;
	}

	/**
	 * Read the plain-permalink REST route fallback from the query string.
	 *
	 * This is intentionally private and limited to the `rest_route` router
	 * fallback; REST callbacks receive their route from WP_REST_Request.
	 *
	 * @return string Sanitized route or an empty string.
	 */
	private static function get_rest_route_query_var() {
		$value = filter_input( INPUT_GET, 'rest_route', FILTER_SANITIZE_URL );
		if ( ! is_string( $value ) || '' === $value ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( $value ) );
	}

	/**
	 * Whether a REST route is inside the connector namespace.
	 *
	 * @param string $route REST route, with or without a leading slash.
	 * @return bool
	 */
	public static function is_namespace_route( $route ) {
		$route     = '/' . ltrim( (string) $route, '/' );
		$namespace = '/' . self::NAMESPACE;

		return $route === $namespace || 0 === strpos( $route, $namespace . '/' );
	}

	/**
	 * Whether a request URI path targets the connector REST namespace.
	 * Query-string content is intentionally ignored.
	 *
	 * @param string $uri Request URI.
	 * @return bool
	 */
	public static function is_namespace_uri( $uri ) {
		$path = wp_parse_url( (string) $uri, PHP_URL_PATH );
		if ( ! is_string( $path ) || '' === $path ) {
			return false;
		}

		$prefix = '/' . trim( rest_get_url_prefix(), '/' ) . '/' . self::NAMESPACE;
		$offset = strpos( $path, $prefix );
		if ( false === $offset ) {
			return false;
		}

		$remaining = substr( $path, $offset + strlen( $prefix ) );
		return '' === $remaining || '/' === $remaining[0];
	}

	public static function is_namespace_request( $request = null ) {
		if ( $request instanceof \WP_REST_Request ) {
			return self::is_namespace_route( (string) $request->get_route() );
		}

		$request_uri = self::get_server_var( 'REQUEST_URI' );
		$rest_route  = self::get_rest_route_query_var();

		return self::is_namespace_uri( $request_uri ) || self::is_namespace_route( $rest_route );
	}

	// -----------------------------------------------------------------------
	// Route registration
	// -----------------------------------------------------------------------

	/**
	 * Validate the same-origin sitemap path accepted by the proxy.
	 *
	 * The historical character allowlist also accepted dot segments such as
	 * `/../private.xml` and empty segments such as `//sitemap.xml`. Reject
	 * paths that URL clients or web servers could normalize differently.
	 *
	 * @param mixed $value Candidate request value.
	 * @return bool
	 */
	public static function is_valid_sitemap_proxy_path( $value ) {
		if ( ! is_string( $value ) || 1 !== preg_match( '/^\/[\w\-\/\.]+\.xml$/i', $value ) ) {
			return false;
		}

		$segments = explode( '/', substr( $value, 1 ) );
		foreach ( $segments as $segment ) {
			if ( '' === $segment || '.' === $segment || '..' === $segment ) {
				return false;
			}
		}

		return true;
	}

	public static function register_routes() {
		$ns = self::NAMESPACE;

		// Verify (SEC-044 pilot: explicit capability)
		register_rest_route(
			$ns,
			'/verify',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'verify' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_SITE_VERIFY ),
			)
		);

		// Posts (list + create)
		register_rest_route(
			$ns,
			'/posts',
			array(
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'list_posts' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_READ ),
				'args'                => array(
					'per_page' => array(
			'default'           => 20,
			'sanitize_callback' => 'absint',
				),
					'page'     => array(
				'default'           => 1,
				'sanitize_callback' => 'absint',
				),
					'status'   => array(
				'default'           => 'any',
				'sanitize_callback' => 'sanitize_text_field',
				),
					'search'   => array(
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				),
				),
			),
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'create_post' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			),
			)
		);

		// Single post (get + update)
		register_rest_route(
			$ns,
			'/posts/(?P<id>\d+)',
			array(
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'get_post' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_READ ),
			),
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'update_post' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			),
			)
		);

		// Restore revision
		register_rest_route(
			$ns,
			'/posts/(?P<id>\d+)/restore-revision',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'restore_post_revision' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		// Pages (list)
		register_rest_route(
			$ns,
			'/pages',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'list_pages' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_PAGES_READ ),
			'args'                => array(
				'per_page' => array(
			'default'           => 20,
			'sanitize_callback' => 'absint',
			),
				'page'     => array(
			'default'           => 1,
			'sanitize_callback' => 'absint',
			),
			),
			)
		);

		// Single page (get + update)
		register_rest_route(
			$ns,
			'/pages/(?P<id>\d+)',
			array(
			array(
				'methods'             => 'GET',
				'callback'            => array( __CLASS__, 'get_page' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_PAGES_READ ),
			),
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'update_page' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_PAGES_WRITE ),
			),
			)
		);

		register_rest_route(
			$ns,
			'/pages/(?P<id>\d+)/restore-revision',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'restore_page_revision' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_PAGES_WRITE ),
			)
		);

		// Categories
		register_rest_route(
			$ns,
			'/categories',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'list_categories' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_CATEGORIES_READ ),
			)
		);

		// Media
		register_rest_route(
			$ns,
			'/media',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'list_media' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_MEDIA_READ ),
			'args'                => array(
				'per_page'    => array(
			'default'           => 20,
			'sanitize_callback' => 'absint',
			),
				'page'        => array(
			'default'           => 1,
			'sanitize_callback' => 'absint',
			),
				'search'      => array(
			'default'           => '',
			'sanitize_callback' => 'sanitize_text_field',
			),
				'media_type'  => array(
			'default'           => '',
			'sanitize_callback' => 'sanitize_text_field',
			),
				'mime_type'   => array(
			'default'           => '',
			'sanitize_callback' => 'sanitize_text_field',
			),
			),
			)
		);

		// Media upload
		register_rest_route(
			$ns,
			'/media/upload',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'upload_media' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_MEDIA_WRITE ),
			)
		);

		// Media alt text update
		register_rest_route(
			$ns,
			'/media/(?P<id>\d+)/alt',
			array(
				'methods'             => 'POST',
				'callback'            => array( __CLASS__, 'update_media_alt' ),
				'permission_callback' => self::require_caps( RouteCapabilities::CAP_MEDIA_WRITE ),
				'args'                => array(
					'id' => array(
						'required'          => true,
						'sanitize_callback' => 'absint',
					),
				),
			)
		);

		// Media details update (title, caption)
		register_rest_route(
			$ns,
			'/media/(?P<id>\d+)',
			array(
				array(
					'methods'             => 'POST',
					'callback'            => array( __CLASS__, 'update_media_details' ),
					'permission_callback' => self::require_caps( RouteCapabilities::CAP_MEDIA_WRITE ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);

		// IndexNow
		register_rest_route(
			$ns,
			'/indexnow/setup',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'indexnow_setup' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_INDEXNOW_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/indexnow/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'indexnow_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_INDEXNOW_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/indexnow/submit',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'indexnow_submit' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_INDEXNOW_WRITE ),
			)
		);

		// Sitemap proxy
		register_rest_route(
			$ns,
			'/sitemap-proxy',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'sitemap_proxy' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_SITEMAP_READ ),
			'args'                => array(
				'path' => array(
					'required'          => true,
					'sanitize_callback' => 'sanitize_text_field',
					'validate_callback' => function ( $value ) {
						return self::is_valid_sitemap_proxy_path( $value );
					},
				),
			),
			)
		);

		// SEO capabilities
		register_rest_route(
			$ns,
			'/seo/capabilities',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'seo_capabilities' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_SEO_READ ),
			)
		);

		// Inject image alt
		register_rest_route(
			$ns,
			'/posts/(?P<id>\d+)/inject-image-alt',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'inject_image_alt' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		// Inject AEO snippet
		register_rest_route(
			$ns,
			'/posts/(?P<id>\d+)/inject-aeo',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'inject_aeo' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
			)
		);

		// Featured image — get / set by media ID / remove (Feature 1.3A)
		register_rest_route(
			$ns,
			'/posts/(?P<id>\d+)/featured-image',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( __CLASS__, 'get_featured_image' ),
					'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_READ ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'sanitize_callback' => 'absint',
						),
					),
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( __CLASS__, 'set_featured_image' ),
					'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'sanitize_callback' => 'absint',
						),
					),
				),
				array(
					'methods'             => 'DELETE',
					'callback'            => array( __CLASS__, 'remove_featured_image' ),
					'permission_callback' => self::require_caps( RouteCapabilities::CAP_POSTS_WRITE ),
					'args'                => array(
						'id' => array(
							'required'          => true,
							'sanitize_callback' => 'absint',
						),
					),
				),
			)
		);

		// Internal links sync
		register_rest_route(
			$ns,
			'/internal-links/manifest',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'internal_links_manifest' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_INTERNAL_LINKS_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/internal-links/sync',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'internal_links_sync' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_INTERNAL_LINKS_READ ),
			'args'                => array(
				'cursor'             => array(
			'required'          => false,
			'sanitize_callback' => 'sanitize_text_field',
			),
				'since'              => array(
			'required'          => false,
			'sanitize_callback' => 'sanitize_text_field',
			),
				'limit'              => array(
			'required'          => false,
			'sanitize_callback' => 'absint',
			),
				'types'              => array(
			'required'          => false,
			'sanitize_callback' => 'sanitize_text_field',
			),
				'includeBodyPhrases' => array(
			'required'          => false,
			'sanitize_callback' => 'sanitize_text_field',
			),
			),
			)
		);

		// AI Visibility endpoints
		register_rest_route(
			$ns,
			'/ai-visibility/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'ai_visibility_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/llms/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'llms_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/llms/preview',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'llms_preview' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/llms/publish',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'llms_publish' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/robots/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'robots_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/robots/preview',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'robots_preview' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/robots/apply',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'robots_apply' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/robots/restore',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'robots_restore' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/schema/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'schema_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/schema/preview',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'schema_preview' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/schema/inject',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'schema_inject' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_AI_VISIBILITY_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/crawler-logs/status',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'crawler_logs_status' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_CRAWLER_LOGS_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/crawler-logs/enable',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'crawler_logs_enable' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_CRAWLER_LOGS_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/crawler-logs/disable',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'crawler_logs_disable' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_CRAWLER_LOGS_WRITE ),
			)
		);

		register_rest_route(
			$ns,
			'/crawler-logs',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'crawler_logs_get' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_CRAWLER_LOGS_READ ),
			)
		);

		// Import endpoints
		register_rest_route(
			$ns,
			'/seo/import/summary',
			array(
			'methods'             => 'GET',
			'callback'            => array( __CLASS__, 'import_summary' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_SEO_READ ),
			)
		);

		register_rest_route(
			$ns,
			'/seo/import',
			array(
			'methods'             => 'POST',
			'callback'            => array( __CLASS__, 'import_meta' ),
			'permission_callback' => self::require_caps( RouteCapabilities::CAP_SEO_IMPORT_WRITE ),
			)
		);
	}

	// -----------------------------------------------------------------------
	// Authentication
	// -----------------------------------------------------------------------

	/**
	 * Resolve which configured key the provided secret matches (SEC-045).
	 * Uses timing-safe compares. Order: legacy → read → write.
	 *
	 * @param string $provided_key Raw key from the request.
	 * @return string `legacy`|`read`|`write`|`invalid`
	 */
	public static function resolve_key_type( $provided_key ) {
		$provided_key = is_string( $provided_key ) ? trim( $provided_key ) : '';
		if ( '' === $provided_key ) {
			return 'invalid';
		}

		$legacy = (string) Options::get_site_key();
		if ( '' !== $legacy && hash_equals( $legacy, $provided_key ) ) {
			return 'legacy';
		}

		$read = (string) Options::get_site_read_key();
		if ( '' !== $read && hash_equals( $read, $provided_key ) ) {
			return 'read';
		}

		$write = (string) Options::get_site_write_key();
		if ( '' !== $write && hash_equals( $write, $provided_key ) ) {
			return 'write';
		}

		return 'invalid';
	}

	/**
	 * Whether any connector key (legacy / read / write) is configured.
	 *
	 * @return bool
	 */
	public static function has_any_site_key_configured() {
		return '' !== (string) Options::get_site_key()
			|| '' !== (string) Options::get_site_read_key()
			|| '' !== (string) Options::get_site_write_key();
	}

	/**
	 * Build a permission_callback that requires the given capabilities.
	 *
	 * @param string|string[] $caps Capability or list of capabilities.
	 * @return callable
	 */
	public static function require_caps( $caps ) {
		$caps = is_array( $caps ) ? $caps : array( $caps );
		return static function ( $request ) use ( $caps ) {
			return self::check_site_key( $request, $caps );
		};
	}

	/**
	 * Authenticate the request and enforce required capabilities (SEC-044/045).
	 *
	 * Connector routes authenticate with scoped site keys rather than logged-in
	 * WordPress users because requests originate from the SEOvault connector.
	 *
	 * When `$required_caps` is empty, the capability is resolved from
	 * {@see RouteCapabilities} for the current route/method.
	 *
	 * @param \WP_REST_Request $request        Request.
	 * @param string[]         $required_caps  Required capability strings.
	 * @return true|\WP_Error
	 */
	public static function check_site_key( $request, $required_caps = array() ) {
		// SEC-019: pre-auth global IP throttle runs before key validation.
		$global_limit = RateLimiter::check_global_ip( $request );
		if ( is_wp_error( $global_limit ) ) {
			AuditLog::log_rate_limit( $request, RateLimiter::KEY_GLOBAL_IP );
			return $global_limit;
		}

		// SEC-023: hard lockout blocks even a valid key from the locked IP.
		$lockout = RateLimiter::check_lockout( $request );
		if ( is_wp_error( $lockout ) ) {
			AuditLog::log_lockout( $request );
			return $lockout;
		}

		$provided_key = self::get_provided_site_key( $request );

		if ( empty( $provided_key ) || ! self::has_any_site_key_configured() ) {
			Logger::error( 'API auth failed: site key not configured or not provided', array( 'route' => (string) $request->get_route() ) );
			AuditLog::log_auth_failure( $request, 'no_key' );
			// SEC-015/019/023: auth-fail increment only on the failure path.
			$rate_limit = RateLimiter::record_auth_failure( $request );
			if ( is_wp_error( $rate_limit ) ) {
				if ( RateLimiter::ERROR_CODE_AUTH_LOCKOUT === $rate_limit->get_error_code() ) {
					AuditLog::log_lockout( $request );
				} else {
					AuditLog::log_rate_limit( $request, RateLimiter::KEY_AUTH_FAIL_IP );
				}
				return $rate_limit;
			}
			return new \WP_Error(
				'seovaultai_no_key',
				'Site key not configured or not provided.',
				array( 'status' => 401 )
			);
		}

		$key_type = self::resolve_key_type( $provided_key );
		if ( 'invalid' === $key_type ) {
			Logger::error( 'API auth failed: invalid site key', array( 'route' => (string) $request->get_route() ) );
			AuditLog::log_auth_failure( $request, 'invalid_key' );
			// SEC-015/019/023: auth-fail increment only on the failure path.
			$rate_limit = RateLimiter::record_auth_failure( $request );
			if ( is_wp_error( $rate_limit ) ) {
				if ( RateLimiter::ERROR_CODE_AUTH_LOCKOUT === $rate_limit->get_error_code() ) {
					AuditLog::log_lockout( $request );
				} else {
					AuditLog::log_rate_limit( $request, RateLimiter::KEY_AUTH_FAIL_IP );
				}
				return $rate_limit;
			}
			return new \WP_Error(
				'seovaultai_invalid_key',
				'Invalid site key.',
				array( 'status' => 403 )
			);
		}

		// SEC-025: successful auth clears fail counter + lockout for this IP.
		RateLimiter::clear_auth_failures( $request );

		// SEC-020: post-auth per key-hash + route-bucket limit (after validation).
		$bucket_limit = RateLimiter::check_bucket( $request, $provided_key );
		if ( is_wp_error( $bucket_limit ) ) {
			$bucket = RateLimiter::get_route_bucket( (string) $request->get_route(), (string) $request->get_method() );
			AuditLog::log_rate_limit( $request, $bucket );
			return $bucket_limit;
		}

		$caps = is_array( $required_caps ) ? array_values( array_filter( array_map( 'strval', $required_caps ) ) ) : array();
		if ( empty( $caps ) ) {
			$caps = array(
				RouteCapabilities::get_required_capability(
					(string) $request->get_route(),
					(string) $request->get_method()
				),
			);
		}

		foreach ( $caps as $cap ) {
			if ( ! RouteCapabilities::key_type_allows( $key_type, $cap ) ) {
				Logger::error(
					'API auth failed: insufficient key capability',
					array(
						'route'    => (string) $request->get_route(),
						'key_type' => $key_type,
						'cap'      => $cap,
					)
				);
				return new \WP_Error(
					'seovaultai_insufficient_capability',
					'This API key does not have permission for this operation.',
					array(
						'status'    => 403,
						'key_type'  => $key_type,
						'required'  => $cap,
					)
				);
			}
		}

		Logger::log(
			'API auth passed',
			array(
				'route'    => (string) $request->get_route(),
				'key_type' => $key_type,
			)
		);
		return true;
	}

	private static function get_provided_site_key( \WP_REST_Request $request ) {
		$provided_key = $request->get_header( 'X-Site-Key' );
		if ( is_string( $provided_key ) && '' !== trim( $provided_key ) ) {
			return trim( $provided_key );
		}

		$auth_header = $request->get_header( 'Authorization' );
		if ( ! is_string( $auth_header ) || '' === trim( $auth_header ) ) {
			return '';
		}

		$auth_header = trim( $auth_header );
		if ( 0 === stripos( $auth_header, 'Bearer ' ) ) {
			$auth_header = substr( $auth_header, 7 );
		}

		return is_string( $auth_header ) ? trim( $auth_header ) : '';
	}

	/**
	 * Log successful write requests to the audit log (SEC-007).
	 *
	 * Hooked onto `rest_request_after_callbacks`, which runs once per
	 * dispatched REST request immediately after the route callback (or
	 * the `permission_callback` failure) has produced a result. Only
	 * `seovaultai/v1` requests using a write HTTP method
	 * ({@see WRITE_METHODS}) that did *not* fail (no `WP_Error` response,
	 * i.e. the permission check passed and the callback ran without
	 * error) are logged; GET requests and any failed request (auth
	 * failure or business-logic error) are left untouched here.
	 *
	 * @param mixed             $response Dispatch result, usually a WP_REST_Response or WP_Error.
	 * @param array             $handler  Route handler used for the request.
	 * @param \WP_REST_Request  $request  Request used to generate the response.
	 * @return mixed The unmodified $response (this hook only observes, never alters, the response).
	 */
	public static function log_write_success( $response, $handler, $request ) {
		if ( ! $request instanceof \WP_REST_Request || ! self::is_namespace_request( $request ) ) {
			return $response;
		}

		if ( ! in_array( strtoupper( (string) $request->get_method() ), self::WRITE_METHODS, true ) ) {
			return $response;
		}

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

		AuditLog::log_write_success( $request, $response );

		return $response;
	}

	/**
	 * Log sampled successful read requests to the audit log (SEC-009).
	 *
	 * Hooked onto `rest_request_after_callbacks`, same as
	 * {@see log_write_success()}. Only `seovaultai/v1` requests using a
	 * non-write, non-`OPTIONS` HTTP method (i.e. reads) that did not
	 * fail are candidates; `AuditLog::log_read_success()` itself gates
	 * on the `seovaultai_api_audit_log_read_success` filter (off by
	 * default) and the configured sample rate, so most calls here are a
	 * no-op.
	 *
	 * @param mixed             $response Dispatch result, usually a WP_REST_Response or WP_Error.
	 * @param array             $handler  Route handler used for the request.
	 * @param \WP_REST_Request  $request  Request used to generate the response.
	 * @return mixed The unmodified $response (this hook only observes, never alters, the response).
	 */
	public static function log_read_success( $response, $handler, $request ) {
		if ( ! $request instanceof \WP_REST_Request || ! self::is_namespace_request( $request ) ) {
			return $response;
		}

		$method = strtoupper( (string) $request->get_method() );
		if ( 'OPTIONS' === $method || in_array( $method, self::WRITE_METHODS, true ) ) {
			return $response;
		}

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

		AuditLog::log_read_success( $request, $response );

		return $response;
	}

	// -----------------------------------------------------------------------
	// Helpers
	// -----------------------------------------------------------------------

	private static function resolve_author_id() {
		$author_id = (int) Options::get_default_author();
		if ( $author_id && get_userdata( $author_id ) && user_can( $author_id, 'edit_posts' ) ) {
			return $author_id;
		}

		$admins = get_users(
			array(
			'role'   => 'administrator',
			'number' => 1,
			'fields' => 'ID',
			)
		);
		if ( empty( $admins ) ) {
			return 0;
		}

		return (int) $admins[0];
	}

	private static function prepare_post_content( $content ) {
		if ( is_null( $content ) ) {
			return '';
		}
		return is_string( $content ) ? $content : wp_json_encode( $content );
	}

	private static function run_with_unfiltered_post_html( callable $callback ) {
		$removed_kses = false;

		if ( function_exists( 'kses_remove_filters' ) ) {
			kses_remove_filters();
			$removed_kses = true;
		}

		try {
			return $callback();
		} finally {
			if ( $removed_kses && function_exists( 'kses_init_filters' ) ) {
				kses_init_filters();
			}
		}
	}

	private static function collect_category_ids( array $payload ) {
		$category_ids = array();

		if ( isset( $payload['category_ids'] ) && is_array( $payload['category_ids'] ) ) {
			foreach ( $payload['category_ids'] as $category_id ) {
				$category_ids[] = absint( $category_id );
			}
		}

		if ( isset( $payload['category_id'] ) ) {
			$category_ids[] = absint( $payload['category_id'] );
		}

		if ( isset( $payload['category'] ) ) {
			if ( is_numeric( $payload['category'] ) ) {
				$category_ids[] = absint( $payload['category'] );
			} elseif ( is_string( $payload['category'] ) ) {
				$category_id = get_cat_ID( sanitize_text_field( $payload['category'] ) );
				if ( $category_id > 0 ) {
					$category_ids[] = $category_id;
				}
			}
		}

		if ( isset( $payload['categories'] ) && is_array( $payload['categories'] ) ) {
			foreach ( $payload['categories'] as $category ) {
				if ( is_numeric( $category ) ) {
					$category_ids[] = absint( $category );
					continue;
				}

				if ( is_array( $category ) ) {
					if ( isset( $category['id'] ) ) {
						$category_ids[] = absint( $category['id'] );
						continue;
					}
					if ( isset( $category['name'] ) && is_string( $category['name'] ) ) {
						$category_id = get_cat_ID( sanitize_text_field( $category['name'] ) );
						if ( $category_id > 0 ) {
							$category_ids[] = $category_id;
						}
					}
				}
			}
		}

		$category_ids = array_values( array_unique( array_filter( array_map( 'absint', $category_ids ) ) ) );
		return $category_ids;
	}

	private static function default_category_ids() {
		$default_category = (int) Options::get_default_category();
		if ( $default_category <= 0 ) {
			return array();
		}
		return array( $default_category );
	}

	private static function build_seo_meta_input( array $payload ) {
		$meta_input = array();

		if ( array_key_exists( 'seo_title', $payload ) ) {
			$seo_title                        = sanitize_text_field( (string) $payload['seo_title'] );
			$meta_input['_yoast_wpseo_title'] = $seo_title;
			$meta_input['rank_math_title']    = $seo_title;
			$meta_input['_aioseo_title']      = $seo_title;
		}

		if ( array_key_exists( 'seo_description', $payload ) ) {
			$seo_description                     = sanitize_textarea_field( (string) $payload['seo_description'] );
			$meta_input['_yoast_wpseo_metadesc'] = $seo_description;
			$meta_input['rank_math_description'] = $seo_description;
			$meta_input['_aioseo_description']   = $seo_description;
		}

		if ( array_key_exists( 'focus_keyword', $payload ) ) {
			$focus_keyword                         = sanitize_text_field( (string) $payload['focus_keyword'] );
			$meta_input['_yoast_wpseo_focuskw']    = $focus_keyword;
			$meta_input['rank_math_focus_keyword'] = $focus_keyword;
			$meta_input['_aioseo_focus_keyword']   = $focus_keyword;
		}

		return $meta_input;
	}

	private static function normalize_secondary_keywords( $value ) {
		if ( is_array( $value ) ) {
			$keywords = array_map(
				static function ( $keyword ) {
					return sanitize_text_field( (string) $keyword );
				},
				$value
			);
			$keywords = array_filter( array_map( 'trim', $keywords ) );
			return implode( ', ', $keywords );
		}

		return sanitize_text_field( (string) $value );
	}

	private static function get_seo_meta_value( $post_id, array $keys ) {
		foreach ( $keys as $key ) {
			$value = get_post_meta( $post_id, $key, true );
			if ( is_string( $value ) && '' !== trim( $value ) ) {
				return trim( $value );
			}
		}
		return null;
	}

	private static function rebuild_yoast_indexable( $post_id ) {
		if ( function_exists( 'YoastSEO' ) ) {
			try {
				$yoast = call_user_func( 'YoastSEO' );
				if ( ! is_object( $yoast ) || ! isset( $yoast->classes ) ) {
					return;
				}

				$container  = $yoast->classes;
				$repository = $container->get( 'Yoast\\WP\\SEO\\Repositories\\Indexable_Repository' );
				$builder    = $container->get( 'Yoast\\WP\\SEO\\Builders\\Indexable_Builder' );

				$indexable = $repository->find_by_id_and_type( $post_id, 'post' );
				if ( $indexable ) {
					$builder->build_for_id_and_type( $post_id, 'post', $indexable );
					$indexable->save();
				}
				return;
			} catch ( \Exception $exception ) {
			}
		}

		if ( class_exists( 'WPSEO_Meta' ) ) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Yoast SEO integration hook
			do_action( 'wpseo_save_indexable', $post_id, get_post( $post_id ) );
		}
	}

	/**
	 * Detect image MIME from file contents only (no extension fallback).
	 *
	 * @param string $file_path Absolute path.
	 * @return string MIME type or empty string when not a detectable image.
	 */
	private static function detect_image_mime_from_file( $file_path ) {
		if ( ! is_string( $file_path ) || '' === $file_path || ! file_exists( $file_path ) ) {
			return '';
		}

		if ( function_exists( 'wp_get_image_mime' ) ) {
			$mime = wp_get_image_mime( $file_path );
			if ( is_string( $mime ) && '' !== trim( $mime ) ) {
				return trim( $mime );
			}
		}

		if ( function_exists( 'finfo_open' ) && defined( 'FILEINFO_MIME_TYPE' ) ) {
			$finfo = finfo_open( FILEINFO_MIME_TYPE );
			if ( false !== $finfo ) {
				$mime = finfo_file( $finfo, $file_path );
				finfo_close( $finfo );
				if ( is_string( $mime ) && 0 === strpos( $mime, 'image/' ) ) {
					return trim( $mime );
				}
			}
		}

		if ( function_exists( 'mime_content_type' ) ) {
			$mime = mime_content_type( $file_path );
			if ( is_string( $mime ) && 0 === strpos( $mime, 'image/' ) ) {
				return trim( $mime );
			}
		}

		if ( function_exists( 'getimagesize' ) ) {
			$image_data = @getimagesize( $file_path );
			if ( is_array( $image_data ) && ! empty( $image_data['mime'] ) ) {
				return (string) $image_data['mime'];
			}
		}

		if ( function_exists( 'exif_imagetype' ) ) {
			$image_type = @exif_imagetype( $file_path );
			$exif_map   = array(
				IMAGETYPE_JPEG => 'image/jpeg',
				IMAGETYPE_PNG  => 'image/png',
				IMAGETYPE_GIF  => 'image/gif',
				IMAGETYPE_WEBP => 'image/webp',
			);
			if ( isset( $exif_map[ $image_type ] ) ) {
				return $exif_map[ $image_type ];
			}
		}

		return '';
	}

	private static function get_file_mime_type( $file_path, $url = '' ) {
		if ( function_exists( 'mime_content_type' ) ) {
			$mime = mime_content_type( $file_path );
			if ( is_string( $mime ) && '' !== trim( $mime ) ) {
				return trim( $mime );
			}
		}

		if ( function_exists( 'finfo_open' ) && defined( 'FILEINFO_MIME_TYPE' ) ) {
			$finfo = finfo_open( FILEINFO_MIME_TYPE );
			if ( false !== $finfo ) {
				$mime = finfo_file( $finfo, $file_path );
				finfo_close( $finfo );
				if ( is_string( $mime ) && '' !== trim( $mime ) ) {
					return trim( $mime );
				}
			}
		}

		if ( function_exists( 'getimagesize' ) ) {
			$image_data = @getimagesize( $file_path );
			if ( is_array( $image_data ) && ! empty( $image_data['mime'] ) ) {
				return (string) $image_data['mime'];
			}
		}

		$path  = $url ? (string) wp_parse_url( $url, PHP_URL_PATH ) : $file_path;
		$check = wp_check_filetype( $path );
		if ( ! empty( $check['type'] ) && is_string( $check['type'] ) ) {
			return $check['type'];
		}

		$extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
		$mime_map  = array(
			'jpg'  => 'image/jpeg',
			'jpeg' => 'image/jpeg',
			'png'  => 'image/png',
			'gif'  => 'image/gif',
			'webp' => 'image/webp',
		);
		if ( isset( $mime_map[ $extension ] ) ) {
			return $mime_map[ $extension ];
		}

		if ( function_exists( 'exif_imagetype' ) ) {
			$image_type = @exif_imagetype( $file_path );
			$exif_map   = array(
				IMAGETYPE_JPEG => 'image/jpeg',
				IMAGETYPE_PNG  => 'image/png',
				IMAGETYPE_GIF  => 'image/gif',
				IMAGETYPE_WEBP => 'image/webp',
			);
			if ( isset( $exif_map[ $image_type ] ) ) {
				return $exif_map[ $image_type ];
			}
		}

		return 'application/octet-stream';
	}

	private static function find_attachment_id_by_meta( $meta_key, $meta_value ) {
		$meta_value = is_string( $meta_value ) ? trim( $meta_value ) : '';
		if ( '' === $meta_key || '' === $meta_value ) {
			return 0;
		}

		// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
		$existing = get_posts(
			array(
				'post_type'              => 'attachment',
				'post_status'            => 'inherit',
				'posts_per_page'         => 1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'meta_key'               => $meta_key,
				'meta_value'             => $meta_value,
			)
		);
		// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value

		return ! empty( $existing ) ? (int) $existing[0] : 0;
	}

	/**
	 * Apply an existing attachment as featured image and refresh alt text.
	 *
	 * @param int         $post_id       Post ID.
	 * @param int         $attachment_id Attachment ID.
	 * @param string      $title         Fallback alt title.
	 * @param string|null $alt_text      Optional alt text.
	 * @return int
	 */
	private static function reuse_featured_attachment( $post_id, $attachment_id, $title, $alt_text = null ) {
		set_post_thumbnail( $post_id, $attachment_id );

		$alt_value = is_string( $alt_text ) && '' !== trim( $alt_text )
			? sanitize_text_field( $alt_text )
			: sanitize_text_field( $title );
		update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_value );

		return (int) $attachment_id;
	}

	private static function attach_featured_image( $post_id, $image_url, $title, $alt_text = null, $source_asset_id = '' ) {
		require_once ABSPATH . 'wp-admin/includes/media.php';
		require_once ABSPATH . 'wp-admin/includes/file.php';
		require_once ABSPATH . 'wp-admin/includes/image.php';

		$source_asset_id = is_string( $source_asset_id ) ? sanitize_text_field( trim( $source_asset_id ) ) : '';
		if ( '' !== $source_asset_id ) {
			$by_asset = self::find_attachment_id_by_meta( '_seovault_source_asset_id', $source_asset_id );
			if ( $by_asset > 0 ) {
				return self::reuse_featured_attachment( $post_id, $by_asset, $title, $alt_text );
			}
		}

		$image_url = esc_url_raw( trim( $image_url ) );
		if ( '' === $image_url ) {
			return null;
		}

		$scheme = wp_parse_url( $image_url, PHP_URL_SCHEME );
		if ( ! is_string( $scheme ) || ! in_array( strtolower( $scheme ), array( 'http', 'https' ), true ) ) {
			return null;
		}

		// Reject private/reserved hosts and non-public URLs (SSRF hardening).
		if ( function_exists( 'wp_http_validate_url' ) && false === wp_http_validate_url( $image_url ) ) {
			return null;
		}

		// Exact URL match (lowest-priority durable key — prefer asset id / content hash).
		$by_url = self::find_attachment_id_by_meta( '_seovaultai_source_image_url', $image_url );
		if ( $by_url > 0 ) {
			return self::reuse_featured_attachment( $post_id, $by_url, $title, $alt_text );
		}

		$tmp_file = download_url( $image_url, 30 );
		if ( is_wp_error( $tmp_file ) ) {
			return null;
		}

		$source_hash = hash_file( 'sha256', $tmp_file );
		if ( is_string( $source_hash ) && '' !== $source_hash ) {
			$by_hash = self::find_attachment_id_by_meta( '_seovault_source_hash', $source_hash );
			if ( $by_hash > 0 ) {
				if ( file_exists( $tmp_file ) ) {
					wp_delete_file( $tmp_file );
				}
				return self::reuse_featured_attachment( $post_id, $by_hash, $title, $alt_text );
			}
		}

		$mime_type     = self::get_file_mime_type( $tmp_file, $image_url );
		$allowed_mimes = array(
			'image/jpeg' => 'jpg',
			'image/png'  => 'png',
			'image/gif'  => 'gif',
			'image/webp' => 'webp',
		);

		if ( ! isset( $allowed_mimes[ $mime_type ] ) || false !== strpos( $mime_type, 'svg' ) ) {
			if ( file_exists( $tmp_file ) ) {
				wp_delete_file( $tmp_file );
			}
			return null;
		}

		$filename   = sanitize_file_name( substr( sanitize_title( $title ), 0, 50 ) . '-featured.' . $allowed_mimes[ $mime_type ] );
		$file_array = array(
			'name'     => $filename,
			'tmp_name' => $tmp_file,
		);

		$attachment_id = media_handle_sideload( $file_array, $post_id, sanitize_text_field( $title ) );
		if ( file_exists( $tmp_file ) ) {
			wp_delete_file( $tmp_file );
		}

		if ( is_wp_error( $attachment_id ) ) {
			return null;
		}

		set_post_thumbnail( $post_id, $attachment_id );

		$alt_value = is_string( $alt_text ) && '' !== trim( $alt_text )
			? sanitize_text_field( $alt_text )
			: sanitize_text_field( $title );
		update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_value );
		update_post_meta( $attachment_id, '_seovaultai_source_image_url', $image_url );
		if ( is_string( $source_hash ) && '' !== $source_hash ) {
			update_post_meta( $attachment_id, '_seovault_source_hash', $source_hash );
		}
		if ( '' !== $source_asset_id ) {
			update_post_meta( $attachment_id, '_seovault_source_asset_id', $source_asset_id );
		}

		return (int) $attachment_id;
	}

	private static function sync_featured_image( $post_id, array $payload, $title ) {
		if ( ! array_key_exists( 'featured_image_url', $payload ) ) {
			$current_thumbnail = get_post_thumbnail_id( $post_id );
			return $current_thumbnail ? (int) $current_thumbnail : null;
		}

		$image_url = is_string( $payload['featured_image_url'] ) ? trim( $payload['featured_image_url'] ) : '';
		if ( '' === $image_url ) {
			delete_post_thumbnail( $post_id );
			return null;
		}

		$alt_text = isset( $payload['featured_image_alt'] ) && is_string( $payload['featured_image_alt'] )
			? $payload['featured_image_alt']
			: null;

		$source_asset_id = '';
		if ( isset( $payload['featured_image_asset_id'] ) && is_string( $payload['featured_image_asset_id'] ) ) {
			$source_asset_id = $payload['featured_image_asset_id'];
		} elseif ( isset( $payload['source_asset_id'] ) && is_string( $payload['source_asset_id'] ) ) {
			$source_asset_id = $payload['source_asset_id'];
		}

		return self::attach_featured_image( $post_id, $image_url, $title, $alt_text, $source_asset_id );
	}

	private static function indexnow_key_path( $key ) {
		if ( ! \SEOvaultSEO\IndexNow\IndexNowApi::is_valid_key( $key ) ) {
			return '';
		}
		return trailingslashit( ABSPATH ) . $key . '.txt';
	}

	private static function put_file_contents( $file_path, $content ) {
		require_once ABSPATH . 'wp-admin/includes/file.php';

		global $wp_filesystem;

		if ( ! is_object( $wp_filesystem ) && ! WP_Filesystem() ) {
			return false;
		}

		return $wp_filesystem->put_contents( $file_path, $content, FS_CHMOD_FILE );
	}

	// -----------------------------------------------------------------------
	// Formatters
	// -----------------------------------------------------------------------

	private static function format_post_summary( \WP_Post $post ) {
		$category_terms = get_the_terms( $post->ID, 'category' );
		$category_names = array();
		$categories     = array();

		if ( ! is_wp_error( $category_terms ) && is_array( $category_terms ) ) {
			$category_names = array_values(
				array_unique(
					array_filter(
						array_map(
							static function ( \WP_Term $term ) {
								return $term->name;
							},
							$category_terms
						)
					)
				)
			);

			$categories = array_values(
				array_map(
					static function ( \WP_Term $term ) {
						return array(
							'id'   => (int) $term->term_id,
							'name' => $term->name,
							'slug' => $term->slug,
						);
					},
					$category_terms
				)
			);
		}

		$published_gmt        = get_post_time( DATE_ATOM, true, $post );
		$published_local      = get_post_time( DATE_ATOM, false, $post );
		$modified_gmt         = get_post_modified_time( DATE_ATOM, true, $post );
		$modified_local       = get_post_modified_time( DATE_ATOM, false, $post );
		$legacy_published_gmt = $post->post_date_gmt && $post->post_date_gmt !== '0000-00-00 00:00:00'
			? $post->post_date_gmt
			: '';
		$legacy_modified_gmt  = $post->post_modified_gmt && $post->post_modified_gmt !== '0000-00-00 00:00:00'
			? $post->post_modified_gmt
			: '';

		return array(
			'id'             => $post->ID,
			'title'          => $post->post_title,
			'status'         => $post->post_status,
			'type'           => $post->post_type,
			'date'           => $published_gmt ?: $published_local,
			'date_gmt'       => $legacy_published_gmt,
			'date_local'     => $post->post_date,
			'published_at'   => $published_gmt ?: $published_local,
			'modified'       => $modified_gmt ?: $modified_local,
			'modified_gmt'   => $legacy_modified_gmt,
			'modified_local' => $post->post_modified,
			'modified_at'    => $modified_gmt ?: $modified_local,
			'slug'           => $post->post_name,
			'author'         => get_the_author_meta( 'display_name', $post->post_author ),
			'word_count'     => str_word_count( wp_strip_all_tags( $post->post_content ) ),
			'permalink'      => get_permalink( $post->ID ),
			'category_names' => $category_names,
			'categories'     => $categories,
		);
	}

	private static function format_post_full( \WP_Post $post ) {
		$summary = self::format_post_summary( $post );

		$summary['content']   = $post->post_content;
		$summary['excerpt']   = $post->post_excerpt;
		$summary['permalink'] = get_permalink( $post->ID );
		$summary['thumbnail'] = get_the_post_thumbnail_url( $post->ID, 'medium' ) ?: null;

		$thumbnail_id = (int) get_post_thumbnail_id( $post->ID );
		if ( $thumbnail_id ) {
			$attachment = get_post( $thumbnail_id );
			if ( $attachment ) {
				$mime_type = $attachment->post_mime_type;
				if ( $mime_type && strpos( $mime_type, 'image/' ) === 0 ) {
					$summary['featured_image'] = self::format_featured_image( $thumbnail_id );
				} else {
					// Non-image attachment — do not expose as valid featured image
					$summary['featured_image'] = null;
				}
			} else {
				// Attachment deleted
				$summary['featured_image'] = null;
			}
		} else {
			$summary['featured_image'] = null;
		}

		$summary['seo'] = array(
			'title'              => self::get_seo_meta_value( $post->ID, array( '_seovault_seo_title', '_yoast_wpseo_title', 'rank_math_title', '_aioseo_title' ) ),
			'description'        => self::get_seo_meta_value( $post->ID, array( '_seovault_seo_description', '_yoast_wpseo_metadesc', 'rank_math_description', '_aioseo_description' ) ),
			'keyword'            => self::get_seo_meta_value( $post->ID, array( '_seovault_focus_keyword', '_yoast_wpseo_focuskw', 'rank_math_focus_keyword', '_aioseo_focus_keyword' ) ),
			'secondary_keywords' => PostMeta::get_secondary_keywords( $post->ID ),
		);

		return $summary;
	}

	private static function capture_pre_change_revision_id( \WP_Post $post ) {
		if ( ! post_type_supports( $post->post_type, 'revisions' ) ) {
			return null;
		}

		$revision_id = wp_save_post_revision( $post->ID );
		if ( ! empty( $revision_id ) && ! is_wp_error( $revision_id ) ) {
			return (int) $revision_id;
		}

		$revisions    = wp_get_post_revisions( $post->ID );
		$revision_ids = array_keys( $revisions );
		if ( empty( $revision_ids ) ) {
			return null;
		}

		return (int) reset( $revision_ids );
	}

	private static function build_update_meta( \WP_Post $before, \WP_Post $after, $pre_change_revision_id = null ) {
		$revisions       = wp_get_post_revisions( $after->ID );
		$revision_ids    = array_keys( $revisions );
		$latest_revision = ! empty( $revision_ids ) ? (int) reset( $revision_ids ) : null;

		return array(
			'pre_change_revision_id'   => ! empty( $pre_change_revision_id ) ? (int) $pre_change_revision_id : null,
			'post_change_revision_id'  => $latest_revision,
			'pre_change_content_hash'  => hash( 'sha256', (string) $before->post_content ),
			'post_change_content_hash' => hash( 'sha256', (string) $after->post_content ),
			'post_status'              => $after->post_status,
			'post_id'                  => (int) $after->ID,
			'post_type'                => (string) $after->post_type,
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Verify
	// -----------------------------------------------------------------------

	public static function verify( $request ) {
		Logger::log( 'API verify: entered' );
		Options::set_connection_verified( 'yes' );
		Options::set_connected_at( gmdate( 'Y-m-d H:i:s' ) );
		Logger::log( 'API verify: connection verified', array( 'plugin_version' => SEOVAULT_SEO_VERSION ) );

		return rest_ensure_response(
			array(
			'ok'                  => true,
			'site_name'           => get_bloginfo( 'name' ),
			'site_url'            => home_url(),
			'wp_version'          => get_bloginfo( 'version' ),
			'plugin_version'      => SEOVAULT_SEO_VERSION,
			'timezone'            => wp_timezone_string(),
			'post_count'          => (int) wp_count_posts()->publish,
			'page_count'          => (int) wp_count_posts( 'page' )->publish,
			'indexnow_configured' => ! empty( Options::get_indexnow_key() ),
			) + FaqConnectorContract::handshake() + SchemaInstanceConnectorContract::handshake()
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Posts
	// -----------------------------------------------------------------------

	public static function list_posts( $request ) {
		$per_page = min( $request->get_param( 'per_page' ), 100 );
		$page     = max( $request->get_param( 'page' ), 1 );
		$status   = $request->get_param( 'status' );
		$search   = $request->get_param( 'search' );

		$args = array(
			'post_type'      => 'post',
			'post_status'    => $status === 'any' ? array( 'publish', 'draft', 'pending', 'private' ) : $status,
			'posts_per_page' => $per_page,
			'paged'          => $page,
			'orderby'        => 'modified',
			'order'          => 'DESC',
		);

		if ( ! empty( $search ) ) {
			$args['s'] = $search;
		}

		$query = new \WP_Query( $args );
		$posts = array();

		foreach ( $query->posts as $post ) {
			$posts[] = self::format_post_summary( $post );
		}

		return rest_ensure_response(
			array(
			'ok'          => true,
			'posts'       => $posts,
			'total'       => (int) $query->found_posts,
			'total_pages' => (int) $query->max_num_pages,
			'page'        => $page,
			)
		);
	}

	public static function get_post( $request ) {
		$post = get_post( $request->get_param( 'id' ) );

		if ( ! $post || $post->post_type !== 'post' ) {
			return new \WP_Error( 'not_found', 'Post not found.', array( 'status' => 404 ) );
		}

		return rest_ensure_response(
			array(
			'ok'   => true,
			'post' => self::format_post_full( $post ),
			)
		);
	}

	public static function create_post( $request ) {
		$json = $request->get_json_params();
		if ( ! is_array( $json ) ) {
			$json = array();
		}

		Logger::log(
			'API create_post: entered',
			array(
			'title'  => isset( $json['title'] ) ? sanitize_text_field( $json['title'] ) : '',
			'status' => $json['status'] ?? 'draft',
			)
		);

		$author_id = self::resolve_author_id();
		if ( ! $author_id ) {
			Logger::error( 'API create_post: no admin user found' );
			return new \WP_Error( 'no_admin', 'No administrator user found on this site.', array( 'status' => 500 ) );
		}

		$post_data = array(
			'post_type'    => 'post',
			'post_status'  => 'draft',
			'post_title'   => '',
			'post_content' => '',
			'post_author'  => $author_id,
		);

		if ( isset( $json['title'] ) ) {
			$post_data['post_title'] = sanitize_text_field( $json['title'] );
		}
		if ( isset( $json['content'] ) ) {
			$post_data['post_content'] = self::prepare_post_content( $json['content'] );
		}
		if ( isset( $json['excerpt'] ) ) {
			$post_data['post_excerpt'] = sanitize_textarea_field( $json['excerpt'] );
		}
		if ( isset( $json['slug'] ) ) {
			$post_data['post_name'] = sanitize_title( $json['slug'] );
		}
		if ( isset( $json['status'] ) && in_array( $json['status'], array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
			$post_data['post_status'] = $json['status'];
		}

		$seo_meta = self::build_seo_meta_input( $json );
		if ( ! empty( $seo_meta ) ) {
			$post_data['meta_input'] = $seo_meta;
		}

		$category_ids = self::collect_category_ids( $json );
		if ( empty( $category_ids ) ) {
			$category_ids = self::default_category_ids();
		}
		if ( ! empty( $category_ids ) ) {
			$post_data['post_category'] = $category_ids;
		}

		if (
			'' === trim( (string) $post_data['post_title'] ) &&
			'' === trim( (string) $post_data['post_content'] )
		) {
			$post_data['post_title'] = 'Untitled Draft';
		}

		$post_id = self::run_with_unfiltered_post_html(
			function () use ( $post_data ) {
				return wp_insert_post( $post_data, true );
			}
		);

		if ( is_wp_error( $post_id ) ) {
			$status = 'empty_content' === $post_id->get_error_code() ? 400 : 500;
			Logger::error(
				'API create_post: wp_insert_post failed',
				array(
				'error_code' => $post_id->get_error_code(),
				'error_msg'  => $post_id->get_error_message(),
				)
			);
			return new \WP_Error( 'create_failed', $post_id->get_error_message(), array( 'status' => $status ) );
		}

		Logger::log( 'API create_post: post created', array( 'post_id' => (int) $post_id ) );

		// Store SEOvault-owned meta
		if ( isset( $json['seo_title'] ) ) {
			PostMeta::set_seo_title( $post_id, $json['seo_title'] );
		}
		if ( isset( $json['seo_description'] ) ) {
			PostMeta::set_seo_description( $post_id, $json['seo_description'] );
		}
		if ( isset( $json['focus_keyword'] ) ) {
			PostMeta::set_focus_keyword( $post_id, $json['focus_keyword'] );
		}
		if ( array_key_exists( 'secondary_keywords', $json ) ) {
			PostMeta::set_secondary_keywords( $post_id, self::normalize_secondary_keywords( $json['secondary_keywords'] ) );
		}

		self::sync_featured_image( $post_id, $json, (string) $post_data['post_title'] );
		self::rebuild_yoast_indexable( $post_id );

		$created = get_post( $post_id );

		return rest_ensure_response(
			array(
			'ok'             => true,
			'post'           => self::format_post_full( $created ),
			'schema_summary' => FaqConnectorContract::build_schema_summary( $created ),
			) + FaqConnectorContract::handshake() + SchemaInstanceConnectorContract::handshake()
		);
	}

	public static function update_post( $request ) {
		$post_id = $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post || $post->post_type !== 'post' ) {
			Logger::error( 'API update_post: post not found', array( 'post_id' => (int) $post_id ) );
			return new \WP_Error( 'not_found', 'Post not found.', array( 'status' => 404 ) );
		}

		Logger::log( 'API update_post: entered', array( 'post_id' => (int) $post_id ) );

		$json = $request->get_json_params();
		if ( ! is_array( $json ) ) {
			$json = array();
		}

		// Optional idempotency: repeating the same key returns the cached success body.
		$idem_key = '';
		if ( isset( $json['idempotency_key'] ) ) {
			$idem_key = FaqConnectorContract::normalize_idempotency_key( $json['idempotency_key'] );
		}
		if ( '' === $idem_key ) {
			$header_idem = $request->get_header( 'Idempotency-Key' );
			if ( is_string( $header_idem ) ) {
				$idem_key = FaqConnectorContract::normalize_idempotency_key( $header_idem );
			}
		}
		if ( '' !== $idem_key ) {
			$cached = FaqConnectorContract::get_idempotent_response( (int) $post_id, $idem_key );
			if ( is_array( $cached ) ) {
				return rest_ensure_response( $cached );
			}
		}

		// Optional stale-write guard (old clients omit content_hash).
		$fresh = FaqConnectorContract::assert_fresh_content( (int) $post_id, $json );
		if ( is_wp_error( $fresh ) ) {
			return $fresh;
		}

		$pre_change_revision_id = self::capture_pre_change_revision_id( $post );

		$update_data = array( 'ID' => $post_id );
		if ( isset( $json['title'] ) ) {
			$update_data['post_title'] = sanitize_text_field( $json['title'] );
		}
		if ( isset( $json['content'] ) ) {
			$update_data['post_content'] = self::prepare_post_content( $json['content'] );
		}
		if ( isset( $json['excerpt'] ) ) {
			$update_data['post_excerpt'] = sanitize_textarea_field( $json['excerpt'] );
		}
		if ( isset( $json['slug'] ) ) {
			$update_data['post_name'] = sanitize_title( $json['slug'] );
		}
		if ( isset( $json['status'] ) && in_array( $json['status'], array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
			$update_data['post_status'] = $json['status'];
		}

		$seo_meta = self::build_seo_meta_input( $json );
		if ( ! empty( $seo_meta ) ) {
			$update_data['meta_input'] = $seo_meta;
		}

		$category_ids = self::collect_category_ids( $json );
		if ( ! empty( $category_ids ) ) {
			$update_data['post_category'] = $category_ids;
		}

		$result = self::run_with_unfiltered_post_html(
			function () use ( $update_data ) {
				return wp_update_post( $update_data, true );
			}
		);

		if ( is_wp_error( $result ) ) {
			Logger::error(
				'API update_post: wp_update_post failed',
				array(
				'post_id'    => (int) $post_id,
				'error_code' => $result->get_error_code(),
				'error_msg'  => $result->get_error_message(),
				)
			);
			return $result;
		}

		Logger::log( 'API update_post: post updated', array( 'post_id' => (int) $post_id ) );

		// Store SEOvault-owned meta
		if ( isset( $json['seo_title'] ) ) {
			PostMeta::set_seo_title( $post_id, $json['seo_title'] );
		}
		if ( isset( $json['seo_description'] ) ) {
			PostMeta::set_seo_description( $post_id, $json['seo_description'] );
		}
		if ( isset( $json['focus_keyword'] ) ) {
			PostMeta::set_focus_keyword( $post_id, $json['focus_keyword'] );
		}
		if ( array_key_exists( 'secondary_keywords', $json ) ) {
			PostMeta::set_secondary_keywords( $post_id, self::normalize_secondary_keywords( $json['secondary_keywords'] ) );
		}

		self::sync_featured_image( (int) $post_id, $json, (string) ( $update_data['post_title'] ?? $post->post_title ) );
		if ( ! empty( $seo_meta ) ) {
			self::rebuild_yoast_indexable( (int) $post_id );
		}

		$updated_post = get_post( $post_id );

		Logger::log(
			'API update_post: success',
			array(
			'post_id'    => (int) $post_id,
			'new_status' => $updated_post->post_status,
			)
		);

		$body = array(
			'ok'             => true,
			'post'           => self::format_post_full( $updated_post ),
			'update_meta'    => self::build_update_meta( $post, $updated_post, $pre_change_revision_id ),
			'schema_summary' => FaqConnectorContract::build_schema_summary( $updated_post ),
		) + FaqConnectorContract::handshake() + SchemaInstanceConnectorContract::handshake();

		if ( '' !== $idem_key ) {
			FaqConnectorContract::store_idempotent_response( (int) $post_id, $idem_key, $body );
		}

		return rest_ensure_response( $body );
	}

	// -----------------------------------------------------------------------
	// Endpoint: Pages
	// -----------------------------------------------------------------------

	public static function list_pages( $request ) {
		$per_page = min( $request->get_param( 'per_page' ), 100 );
		$page     = max( $request->get_param( 'page' ), 1 );

		$query = new \WP_Query(
			array(
			'post_type'      => 'page',
			'post_status'    => array( 'publish', 'draft' ),
			'posts_per_page' => $per_page,
			'paged'          => $page,
			'orderby'        => 'modified',
			'order'          => 'DESC',
			)
		);

		$pages = array();
		foreach ( $query->posts as $post ) {
			$pages[] = self::format_post_summary( $post );
		}

		return rest_ensure_response(
			array(
			'ok'          => true,
			'pages'       => $pages,
			'total'       => (int) $query->found_posts,
			'total_pages' => (int) $query->max_num_pages,
			'page'        => $page,
			)
		);
	}

	public static function get_page( $request ) {
		$page = get_post( $request->get_param( 'id' ) );

		if ( ! $page || $page->post_type !== 'page' ) {
			return new \WP_Error( 'not_found', 'Page not found.', array( 'status' => 404 ) );
		}

		return rest_ensure_response(
			array(
			'ok'   => true,
			'page' => self::format_post_full( $page ),
			)
		);
	}

	public static function update_page( $request ) {
		$page_id = $request->get_param( 'id' );
		$page    = get_post( $page_id );

		if ( ! $page || $page->post_type !== 'page' ) {
			Logger::error( 'API update_page: page not found', array( 'page_id' => (int) $page_id ) );
			return new \WP_Error( 'not_found', 'Page not found.', array( 'status' => 404 ) );
		}

		Logger::log( 'API update_page: entered', array( 'page_id' => (int) $page_id ) );

		$pre_change_revision_id = self::capture_pre_change_revision_id( $page );

		$update_data = array( 'ID' => $page_id );

		$json = $request->get_json_params();
		if ( ! is_array( $json ) ) {
			$json = array();
		}
		if ( isset( $json['title'] ) ) {
			$update_data['post_title'] = sanitize_text_field( $json['title'] );
		}
		if ( isset( $json['content'] ) ) {
			$update_data['post_content'] = self::prepare_post_content( $json['content'] );
		}
		if ( isset( $json['excerpt'] ) ) {
			$update_data['post_excerpt'] = sanitize_textarea_field( $json['excerpt'] );
		}
		if ( isset( $json['slug'] ) ) {
			$update_data['post_name'] = sanitize_title( $json['slug'] );
		}
		if ( isset( $json['status'] ) && in_array( $json['status'], array( 'publish', 'draft', 'pending', 'private' ), true ) ) {
			$update_data['post_status'] = $json['status'];
		}

		$seo_meta = self::build_seo_meta_input( $json );
		if ( ! empty( $seo_meta ) ) {
			$update_data['meta_input'] = $seo_meta;
		}

		$result = self::run_with_unfiltered_post_html(
			function () use ( $update_data ) {
				return wp_update_post( $update_data, true );
			}
		);

		if ( is_wp_error( $result ) ) {
			Logger::error(
				'API update_page: wp_update_post failed',
				array(
				'page_id'    => (int) $page_id,
				'error_code' => $result->get_error_code(),
				'error_msg'  => $result->get_error_message(),
				)
			);
			return $result;
		}

		Logger::log( 'API update_page: page updated', array( 'page_id' => (int) $page_id ) );

		// Store SEOvault-owned meta
		if ( isset( $json['seo_title'] ) ) {
			PostMeta::set_seo_title( $page_id, $json['seo_title'] );
		}
		if ( isset( $json['seo_description'] ) ) {
			PostMeta::set_seo_description( $page_id, $json['seo_description'] );
		}

		$updated_page = get_post( $page_id );

		Logger::log( 'API update_page: success', array( 'page_id' => (int) $page_id ) );

		return rest_ensure_response(
			array(
			'ok'          => true,
			'page'        => self::format_post_full( $updated_page ),
			'update_meta' => self::build_update_meta( $page, $updated_page, $pre_change_revision_id ),
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Restore Revision
	// -----------------------------------------------------------------------

	public static function restore_post_revision( $request ) {
		return self::restore_revision_for_post_type( $request, 'post' );
	}

	public static function restore_page_revision( $request ) {
		return self::restore_revision_for_post_type( $request, 'page' );
	}

	private static function restore_revision_for_post_type( $request, $expected_post_type ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post || $post->post_type !== $expected_post_type ) {
			Logger::error(
				'API restore_revision: not found',
				array(
				'post_id'       => $post_id,
				'expected_type' => $expected_post_type,
				)
			);
			return new \WP_Error( 'not_found', ucfirst( $expected_post_type ) . ' not found.', array( 'status' => 404 ) );
		}

		$json        = $request->get_json_params();
		$revision_id = 0;
		if ( is_array( $json ) && isset( $json['revisionId'] ) ) {
			$revision_id = absint( $json['revisionId'] );
		}
		if ( ! $revision_id ) {
			$revision_id = absint( $request->get_param( 'revisionId' ) );
		}

		if ( ! $revision_id ) {
			Logger::error( 'API restore_revision: missing revision ID', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'missing_revision_id', 'revisionId is required.', array( 'status' => 400 ) );
		}

		$revision = get_post( $revision_id );
		if ( ! $revision || 'revision' !== $revision->post_type || (int) $revision->post_parent !== $post_id ) {
			Logger::error(
				'API restore_revision: invalid revision',
				array(
				'post_id'     => $post_id,
				'revision_id' => $revision_id,
				)
			);
			return new \WP_Error( 'invalid_revision', 'Revision not found for this post.', array( 'status' => 404 ) );
		}

		Logger::log(
			'API restore_revision: restoring',
			array(
			'post_id'     => $post_id,
			'revision_id' => $revision_id,
			)
		);

		$restore_result = wp_restore_post_revision( $revision_id );
		if ( is_wp_error( $restore_result ) ) {
			Logger::error(
				'API restore_revision: wp_restore_post_revision error',
				array(
				'post_id'     => $post_id,
				'revision_id' => $revision_id,
				'error_msg'   => $restore_result->get_error_message(),
				)
			);
			return $restore_result;
		}
		if ( false === $restore_result ) {
			Logger::error(
				'API restore_revision: restore returned false',
				array(
				'post_id'     => $post_id,
				'revision_id' => $revision_id,
				)
			);
			return new \WP_Error( 'restore_failed', 'Failed to restore revision.', array( 'status' => 500 ) );
		}

		$restored_post = get_post( $post_id );
		if ( ! $restored_post ) {
			Logger::error( 'API restore_revision: post could not be reloaded', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'restore_failed', 'Post could not be reloaded after revision restore.', array( 'status' => 500 ) );
		}

		$payload_key = 'page' === $expected_post_type ? 'page' : 'post';

		Logger::log(
			'API restore_revision: success',
			array(
			'post_id'     => $post_id,
			'revision_id' => $revision_id,
			)
		);

		return rest_ensure_response(
			array(
			'ok'                   => true,
			$payload_key           => self::format_post_full( $restored_post ),
			'restored_revision_id' => $revision_id,
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Categories
	// -----------------------------------------------------------------------

	public static function list_categories( $request ) {
		$terms = get_terms(
			array(
			'taxonomy'   => 'category',
			'hide_empty' => false,
			'orderby'    => 'name',
			'order'      => 'ASC',
			)
		);

		if ( is_wp_error( $terms ) ) {
			return new \WP_Error( 'categories_failed', $terms->get_error_message(), array( 'status' => 500 ) );
		}

		$categories = array_map(
			static function ( \WP_Term $term ) {
				return array(
					'id'    => (int) $term->term_id,
					'name'  => $term->name,
					'slug'  => $term->slug,
					'count' => (int) $term->count,
				);
			},
			$terms
		);

		return rest_ensure_response(
			array(
			'ok'         => true,
			'categories' => array_values( $categories ),
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Media
	// -----------------------------------------------------------------------

	public static function list_media( $request ) {
		$per_page   = min( $request->get_param( 'per_page' ), 100 );
		$page       = max( $request->get_param( 'page' ), 1 );
		$search     = $request->get_param( 'search' );
		$media_type = $request->get_param( 'media_type' );
		$mime_type  = $request->get_param( 'mime_type' );

		$query_args = array(
			'post_type'      => 'attachment',
			'post_status'    => 'inherit',
			'posts_per_page' => $per_page,
			'paged'          => $page,
		);

		// MVP: title/filename search via WP_Query `s` (post_title / post_content).
		// Alt-text search (_wp_attachment_image_alt) is deferred.
		if ( ! empty( $search ) ) {
			$query_args['s'] = $search;
		}

		// Broad category filter (e.g. media_type=image matches all image subtypes).
		if ( ! empty( $media_type ) ) {
			$query_args['post_mime_type'] = $media_type;
		}

		// Exact subtype filter. Must be an image/* MIME — no hardcoded whitelist.
		if ( ! empty( $mime_type ) ) {
			$mime_type = strtolower( (string) $mime_type );
			if ( strpos( $mime_type, 'image/' ) !== 0 ) {
				return new \WP_Error(
					'seovaultai_invalid_mime_type',
					__( 'mime_type must be an image type.', 'seovault-ai' ),
					array( 'status' => 400 )
				);
			}
			$query_args['post_mime_type'] = $mime_type;
		}

		$query = new \WP_Query( $query_args );

		$items = array();
		foreach ( $query->posts as $attachment ) {
			$formatted = self::format_media_attachment( $attachment );
			if ( null !== $formatted ) {
				$items[] = $formatted;
			}
		}

		return rest_ensure_response(
			array(
			'ok'          => true,
			'media'       => $items,
			'total'       => (int) $query->found_posts,
			'total_pages' => (int) $query->max_num_pages,
			)
		);
	}

	/**
	 * Format an attachment for the media list / picker response.
	 *
	 * Shared internal serializer for GET /media and future featured-image
	 * consumers (Feature 1.3A). Returns null when the post is not an attachment.
	 *
	 * @param \WP_Post|int $attachment Attachment post object or ID.
	 * @return array|null
	 */
	private static function format_media_attachment( $attachment ) {
		if ( is_numeric( $attachment ) ) {
			$attachment = get_post( (int) $attachment );
		}

		if ( ! $attachment instanceof \WP_Post || 'attachment' !== $attachment->post_type ) {
			return null;
		}

		$metadata = wp_get_attachment_metadata( $attachment->ID );
		$sizes    = array();

		if ( is_array( $metadata ) && ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
			foreach ( $metadata['sizes'] as $size_name => $size_data ) {
				$size_url = wp_get_attachment_image_url( $attachment->ID, $size_name );
				$sizes[ $size_name ] = array(
					'url'    => false === $size_url ? null : $size_url,
					'width'  => isset( $size_data['width'] ) ? (int) $size_data['width'] : 0,
					'height' => isset( $size_data['height'] ) ? (int) $size_data['height'] : 0,
				);
			}
		}

		$full_url = wp_get_attachment_url( $attachment->ID );
		$width    = ( is_array( $metadata ) && isset( $metadata['width'] ) ) ? (int) $metadata['width'] : 0;
		$height   = ( is_array( $metadata ) && isset( $metadata['height'] ) ) ? (int) $metadata['height'] : 0;

		// Always include the full/original size.
		$sizes['full'] = array(
			'url'    => false === $full_url ? null : $full_url,
			'width'  => $width,
			'height' => $height,
		);

		return array(
			'id'        => (int) $attachment->ID,
			'title'     => $attachment->post_title,
			'url'       => false === $full_url ? '' : (string) $full_url,
			'alt'       => (string) get_post_meta( $attachment->ID, '_wp_attachment_image_alt', true ),
			'mime_type' => $attachment->post_mime_type,
			'date'      => $attachment->post_date_gmt,
			'width'     => $width,
			'height'    => $height,
			'sizes'     => $sizes,
		);
	}

	/**
	 * Format an image attachment for featured-image API responses.
	 *
	 * Centralized serializer for GET/POST featured-image and format_post_full
	 * (Feature 1.3A). Returns null when the attachment is missing or not an image.
	 * Distinct from format_media_attachment() which serves the media-list shape.
	 *
	 * @param int $attachment_id Attachment ID.
	 * @return array|null
	 */
	private static function format_featured_image( $attachment_id ) {
		$attachment_id = (int) $attachment_id;
		$attachment    = get_post( $attachment_id );

		if (
			! $attachment instanceof \WP_Post ||
			'attachment' !== $attachment->post_type ||
			! wp_attachment_is_image( $attachment_id )
		) {
			return null;
		}

		$alt_text = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
		$metadata = wp_get_attachment_metadata( $attachment_id );
		if ( ! is_array( $metadata ) ) {
			$metadata = array();
		}

		$named_sizes = array( 'thumbnail', 'medium', 'large' );
		$sizes       = array();

		foreach ( $named_sizes as $size_name ) {
			$image_url           = wp_get_attachment_image_url( $attachment_id, $size_name );
			$sizes[ $size_name ] = array(
				'url'    => false === $image_url ? null : $image_url,
				'width'  => 0,
				'height' => 0,
			);
		}

		if ( ! empty( $metadata['sizes'] ) && is_array( $metadata['sizes'] ) ) {
			foreach ( $metadata['sizes'] as $size_name => $size_data ) {
				if ( ! is_array( $size_data ) ) {
					continue;
				}
				$size_url = wp_get_attachment_image_url( $attachment_id, $size_name );
				if ( isset( $sizes[ $size_name ] ) ) {
					$sizes[ $size_name ]['width']  = isset( $size_data['width'] ) ? (int) $size_data['width'] : 0;
					$sizes[ $size_name ]['height'] = isset( $size_data['height'] ) ? (int) $size_data['height'] : 0;
					if ( false === $sizes[ $size_name ]['url'] || null === $sizes[ $size_name ]['url'] ) {
						$sizes[ $size_name ]['url'] = false === $size_url ? null : $size_url;
					}
				} else {
					$sizes[ $size_name ] = array(
						'url'    => false === $size_url ? null : $size_url,
						'width'  => isset( $size_data['width'] ) ? (int) $size_data['width'] : 0,
						'height' => isset( $size_data['height'] ) ? (int) $size_data['height'] : 0,
					);
				}
			}
		}

		$full_url = wp_get_attachment_url( $attachment_id );
		$width    = isset( $metadata['width'] ) ? (int) $metadata['width'] : 0;
		$height   = isset( $metadata['height'] ) ? (int) $metadata['height'] : 0;

		$sizes['full'] = array(
			'url'    => false === $full_url ? null : $full_url,
			'width'  => $width,
			'height' => $height,
		);

		$attached_file = get_post_meta( $attachment_id, '_wp_attached_file', true );
		$filename      = is_string( $attached_file ) && '' !== $attached_file
			? basename( $attached_file )
			: null;

		$file_size = null;
		$file_path = get_attached_file( $attachment_id );
		if ( is_string( $file_path ) && '' !== $file_path && file_exists( $file_path ) ) {
			$size = filesize( $file_path );
			if ( false !== $size && $size > 0 ) {
				$file_size = (int) $size;
			}
		}

		$thumbnail_url = wp_get_attachment_image_url( $attachment_id, 'thumbnail' );
		$medium_url    = wp_get_attachment_image_url( $attachment_id, 'medium' );
		$large_url     = wp_get_attachment_image_url( $attachment_id, 'large' );

		return array(
			'media_id'  => $attachment_id,
			'url'       => false === $full_url ? '' : (string) $full_url,
			'thumbnail' => false === $thumbnail_url ? null : $thumbnail_url,
			'medium'    => false === $medium_url ? null : $medium_url,
			'large'     => false === $large_url ? null : $large_url,
			'filename'  => $filename,
			'file_size' => $file_size,
			'alt'       => (string) $alt_text,
			'title'     => (string) $attachment->post_title,
			'caption'   => (string) $attachment->post_excerpt,
			'mime_type' => (string) $attachment->post_mime_type,
			'width'     => $width,
			'height'    => $height,
			'sizes'     => $sizes,
		);
	}

	/**
	 * GET /posts/{id}/featured-image — return current featured image details.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function get_featured_image( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post ) {
			return new \WP_Error(
				'seovaultai_post_not_found',
				__( 'Post not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
		if ( ! $thumbnail_id ) {
			return rest_ensure_response(
				array(
					'ok'                 => true,
					'has_featured_image' => false,
					'featured_image'     => null,
				)
			);
		}

		$attachment = get_post( $thumbnail_id );
		if ( ! $attachment ) {
			return rest_ensure_response(
				array(
					'ok'                 => true,
					'has_featured_image' => false,
					'featured_image'     => null,
					'warning'            => 'invalid_featured_attachment',
				)
			);
		}

		$mime_type = $attachment->post_mime_type;
		if ( ! is_string( $mime_type ) || 0 !== strpos( $mime_type, 'image/' ) ) {
			return rest_ensure_response(
				array(
					'ok'                 => true,
					'has_featured_image' => false,
					'featured_image'     => null,
					'warning'            => 'invalid_featured_attachment',
				)
			);
		}

		$featured_image = self::format_featured_image( $thumbnail_id );
		if ( null === $featured_image ) {
			return rest_ensure_response(
				array(
					'ok'                 => true,
					'has_featured_image' => false,
					'featured_image'     => null,
					'warning'            => 'invalid_featured_attachment',
				)
			);
		}

		return rest_ensure_response(
			array(
				'ok'                 => true,
				'has_featured_image' => true,
				'featured_image'     => $featured_image,
			)
		);
	}

	/**
	 * POST /posts/{id}/featured-image — set featured image by media library ID.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function set_featured_image( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post ) {
			return new \WP_Error(
				'seovaultai_post_not_found',
				__( 'Post not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		$params   = $request->get_json_params();
		$media_id = ( is_array( $params ) && isset( $params['media_id'] ) ) ? (int) $params['media_id'] : 0;

		if ( $media_id <= 0 ) {
			return new \WP_Error(
				'seovaultai_missing_media_id',
				__( 'A valid media_id is required.', 'seovault-ai' ),
				array( 'status' => 400 )
			);
		}

		$attachment = get_post( $media_id );
		if ( ! $attachment || 'attachment' !== get_post_type( $media_id ) ) {
			return new \WP_Error(
				'seovaultai_attachment_not_found',
				__( 'Attachment not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		$mime_type = get_post_mime_type( $media_id );
		if ( ! is_string( $mime_type ) || 0 !== strpos( $mime_type, 'image/' ) ) {
			return new \WP_Error(
				'seovaultai_attachment_not_image',
				__( 'The media item must be an image.', 'seovault-ai' ),
				array( 'status' => 400 )
			);
		}

		// Idempotency: already set to this media ID — return success without rewrite.
		$current_thumbnail_id = (int) get_post_meta( $post_id, '_thumbnail_id', true );
		if ( $current_thumbnail_id === $media_id ) {
			return rest_ensure_response(
				array(
					'ok'                 => true,
					'has_featured_image' => true,
					'featured_image'     => self::format_featured_image( $media_id ),
				)
			);
		}

		$result = set_post_thumbnail( $post_id, $media_id );
		if ( ! $result ) {
			return new \WP_Error(
				'seovaultai_featured_image_failed',
				__( 'Failed to set featured image.', 'seovault-ai' ),
				array( 'status' => 500 )
			);
		}

		return rest_ensure_response(
			array(
				'ok'                 => true,
				'has_featured_image' => true,
				'featured_image'     => self::format_featured_image( $media_id ),
			)
		);
	}

	/**
	 * DELETE /posts/{id}/featured-image — remove featured image (idempotent).
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function remove_featured_image( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post ) {
			return new \WP_Error(
				'seovaultai_post_not_found',
				__( 'Post not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		$had_featured = has_post_thumbnail( $post_id );
		if ( $had_featured ) {
			delete_post_thumbnail( $post_id );
		}

		return rest_ensure_response(
			array(
				'ok'                 => true,
				'has_featured_image' => false,
				'featured_image'     => null,
				'changed'            => $had_featured,
			)
		);
	}

	/**
	 * Update alt text on an existing attachment.
	 *
	 * Canonical endpoint shared by the media picker and Feature 1.3A.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function update_media_alt( \WP_REST_Request $request ) {
		$attachment_id = (int) $request->get_param( 'id' );
		$params        = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = array();
		}
		$alt_text = array_key_exists( 'alt_text', $params ) ? (string) $params['alt_text'] : null;

		if ( ! get_post( $attachment_id ) || get_post_type( $attachment_id ) !== 'attachment' ) {
			return new \WP_Error(
				'seovaultai_attachment_not_found',
				__( 'Attachment not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		if ( null === $alt_text ) {
			return new \WP_Error(
				'seovaultai_missing_alt_text',
				__( 'Missing alt_text field.', 'seovault-ai' ),
				array( 'status' => 400 )
			);
		}

		$alt_text = sanitize_text_field( (string) $alt_text );
		if ( function_exists( 'mb_substr' ) ) {
			$alt_text = mb_substr( $alt_text, 0, 500 );
		} else {
			$alt_text = substr( $alt_text, 0, 500 );
		}

		update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt_text );

		return rest_ensure_response(
			array(
				'ok'            => true,
				'attachment_id' => $attachment_id,
				'alt_text'      => $alt_text,
			)
		);
	}

	/**
	 * POST /media/{id} — update attachment title and/or caption.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return \WP_REST_Response|\WP_Error
	 */
	public static function update_media_details( \WP_REST_Request $request ) {
		$attachment_id = (int) $request->get_param( 'id' );
		$attachment    = get_post( $attachment_id );

		if ( ! $attachment || 'attachment' !== get_post_type( $attachment_id ) ) {
			return new \WP_Error(
				'seovaultai_attachment_not_found',
				__( 'Attachment not found.', 'seovault-ai' ),
				array( 'status' => 404 )
			);
		}

		$params = $request->get_json_params();
		if ( ! is_array( $params ) ) {
			$params = array();
		}

		$updated = array();

		if ( array_key_exists( 'title', $params ) ) {
			$title = sanitize_text_field( (string) $params['title'] );
			if ( function_exists( 'mb_substr' ) ) {
				$title = mb_substr( $title, 0, 255 );
			} else {
				$title = substr( $title, 0, 255 );
			}
			$updated['post_title'] = $title;
		}

		if ( array_key_exists( 'caption', $params ) ) {
			$caption = sanitize_textarea_field( (string) $params['caption'] );
			if ( function_exists( 'mb_substr' ) ) {
				$caption = mb_substr( $caption, 0, 1000 );
			} else {
				$caption = substr( $caption, 0, 1000 );
			}
			$updated['post_excerpt'] = $caption;
		}

		if ( empty( $updated ) ) {
			return new \WP_Error(
				'seovaultai_no_fields_to_update',
				__( 'No updatable fields provided.', 'seovault-ai' ),
				array( 'status' => 400 )
			);
		}

		$updated['ID'] = $attachment_id;
		$result        = wp_update_post( $updated, true );
		if ( is_wp_error( $result ) ) {
			return new \WP_Error(
				'seovaultai_media_update_failed',
				__( 'Failed to update media details.', 'seovault-ai' ),
				array( 'status' => 500 )
			);
		}

		$updated_attachment = get_post( $attachment_id );
		$alt_text           = get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );

		return rest_ensure_response(
			array(
				'ok'            => true,
				'attachment_id' => $attachment_id,
				'title'         => $updated_attachment ? (string) $updated_attachment->post_title : '',
				'caption'       => $updated_attachment ? (string) $updated_attachment->post_excerpt : '',
				'alt'           => (string) $alt_text,
			)
		);
	}

	public static function upload_media( \WP_REST_Request $request ) {
		$params = $request->get_json_params();

		$file       = isset( $params['file'] ) ? (string) $params['file'] : '';
		$filename   = isset( $params['filename'] ) ? (string) $params['filename'] : '';
		$alt_text   = isset( $params['alt_text'] ) ? (string) $params['alt_text'] : '';
		$post_id    = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
		$source_key = isset( $params['source_key'] ) ? trim( (string) $params['source_key'] ) : '';

		// Validate required fields.
		if ( '' === $file ) {
			return new \WP_Error(
				'seovaultai_missing_file',
				'The file field is required.',
				array( 'status' => 400 )
			);
		}

		if ( '' === $filename ) {
			return new \WP_Error(
				'seovaultai_missing_filename',
				'The filename field is required.',
				array( 'status' => 400 )
			);
		}

		if (
			'' !== $source_key &&
			( strlen( $source_key ) > 512 || ! preg_match( '#^ultra-images/[A-Za-z0-9._/-]+$#', $source_key ) || false !== strpos( $source_key, '..' ) )
		) {
			return new \WP_Error(
				'seovaultai_invalid_source_key',
				'The generated image source key is invalid.',
				array( 'status' => 400 )
			);
		}

		if ( '' !== $source_key ) {
			// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			$existing = get_posts(
				array(
					'post_type'              => 'attachment',
					'post_status'            => 'inherit',
					'posts_per_page'         => 1,
					'fields'                 => 'ids',
					'no_found_rows'          => true,
					'update_post_meta_cache' => false,
					'update_post_term_cache' => false,
					'meta_key'               => '_seovaultai_source_image_key',
					'meta_value'             => $source_key,
				)
			);
			// phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key, WordPress.DB.SlowDBQuery.slow_db_query_meta_value

			if ( ! empty( $existing ) ) {
				$existing_id   = (int) $existing[0];
				$sanitized_alt = sanitize_text_field( $alt_text );
				if ( '' !== $sanitized_alt ) {
					update_post_meta( $existing_id, '_wp_attachment_image_alt', $sanitized_alt );
				}
				$stored_alt = '' !== $sanitized_alt
					? $sanitized_alt
					: (string) get_post_meta( $existing_id, '_wp_attachment_image_alt', true );
				return self::build_media_upload_response( $existing_id, $stored_alt );
			}
		}

		// Strip data URI prefix if present.
		$file = preg_replace( '/^data:image\/[a-zA-Z0-9.+-]+;base64,/', '', $file );

		// Decode base64 (strict mode to reject invalid data).
		$decoded = base64_decode( $file, true );
		if ( false === $decoded ) {
			return new \WP_Error(
				'seovaultai_invalid_base64',
				'The file data is not valid base64.',
				array( 'status' => 400 )
			);
		}

		if ( '' === $decoded ) {
			return new \WP_Error(
				'seovaultai_empty_file',
				'The decoded file is empty.',
				array( 'status' => 400 )
			);
		}

		// Validate decoded size (3MB max).
		$max_size = 3 * 1024 * 1024; // 3145728 bytes.
		if ( strlen( $decoded ) > $max_size ) {
			return new \WP_Error(
				'seovaultai_file_too_large',
				'The decoded file exceeds the 3MB limit.',
				array( 'status' => 400 )
			);
		}

		// Sanitize filename to basename only (prevent path traversal).
		$filename = sanitize_file_name( $filename );
		$filename = basename( $filename );

		$allowed_types = array(
			'jpg'  => 'image/jpeg',
			'jpeg' => 'image/jpeg',
			'png'  => 'image/png',
			'webp' => 'image/webp',
		);

		$ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
		if ( '' === $ext || ! isset( $allowed_types[ $ext ] ) || 'svg' === $ext || false !== stripos( $filename, '.svg' ) ) {
			return new \WP_Error(
				'seovaultai_invalid_file_type',
				'The file type is not allowed. Allowed types: jpg, jpeg, png, webp.',
				array( 'status' => 400 )
			);
		}

		// Sanitize alt text.
		$sanitized_alt = sanitize_text_field( $alt_text );

		if ( $post_id > 0 && ! get_post( $post_id ) ) {
			$post_id = 0;
		}

		// Write decoded binary to a WordPress-managed temporary file.
		$temp_dir = Paths::temp_dir();
		if ( '' === $temp_dir ) {
			return new \WP_Error(
				'seovaultai_temp_dir_unavailable',
				'The WordPress temporary directory is unavailable.',
				array( 'status' => 500 )
			);
		}

		$temp_path = wp_tempnam( 'seovault-upload-' . $filename, $temp_dir );
		if ( ! is_string( $temp_path ) || '' === $temp_path ) {
			return new \WP_Error(
				'seovaultai_temp_file_failed',
				'Failed to create a temporary file.',
				array( 'status' => 500 )
			);
		}

		$written = file_put_contents( $temp_path, $decoded );

		if ( false === $written ) {
			wp_delete_file( $temp_path );
			return new \WP_Error(
				'seovaultai_temp_write_failed',
				'Failed to write temporary file.',
				array( 'status' => 500 )
			);
		}

		$allowed_mimes = array(
			'jpg|jpeg|jpe' => 'image/jpeg',
			'png'           => 'image/png',
			'webp'          => 'image/webp',
		);

		// Content-aware filetype check (not extension-only).
		$file_info    = wp_check_filetype_and_ext( $temp_path, $filename, $allowed_mimes );
		$checked_ext  = isset( $file_info['ext'] ) ? strtolower( (string) $file_info['ext'] ) : '';
		$checked_type = isset( $file_info['type'] ) ? (string) $file_info['type'] : '';
		$actual_mime  = self::detect_image_mime_from_file( $temp_path );

		$allowed_mime_values = array_values( array_unique( array_values( $allowed_types ) ) );
		$ext_ok              = '' !== $checked_ext && isset( $allowed_types[ $checked_ext ] );
		$type_ok             = '' !== $checked_type && in_array( $checked_type, $allowed_mime_values, true );
		$actual_ok           = '' !== $actual_mime && in_array( $actual_mime, $allowed_mime_values, true );
		$svg_detected        = ( false !== strpos( strtolower( $actual_mime ), 'svg' ) )
			|| ( false !== strpos( strtolower( $checked_type ), 'svg' ) );

		if ( $svg_detected || ! $actual_ok ) {
			if ( file_exists( $temp_path ) ) {
				wp_delete_file( $temp_path );
			}
			return new \WP_Error(
				'seovaultai_invalid_file_type',
				'The file type is not allowed. Allowed types: jpg, jpeg, png, webp.',
				array( 'status' => 400 )
			);
		}

		// Extension / declared type must agree with sniffed image bytes when WP reports them.
		if ( ( $ext_ok && $allowed_types[ $checked_ext ] !== $actual_mime )
			|| ( $type_ok && $checked_type !== $actual_mime ) ) {
			if ( file_exists( $temp_path ) ) {
				wp_delete_file( $temp_path );
			}
			return new \WP_Error(
				'seovaultai_mime_mismatch',
				'The file extension does not match the detected image type.',
				array( 'status' => 400 )
			);
		}

		// If WP could not validate the extension against content, still require filename ext match.
		if ( ! $ext_ok && $allowed_types[ $ext ] !== $actual_mime ) {
			if ( file_exists( $temp_path ) ) {
				wp_delete_file( $temp_path );
			}
			return new \WP_Error(
				'seovaultai_mime_mismatch',
				'The file extension does not match the detected image type.',
				array( 'status' => 400 )
			);
		}

		// Load media administration functions for media_handle_sideload().
		if ( ! function_exists( 'media_handle_sideload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/media.php';
			require_once ABSPATH . 'wp-admin/includes/file.php';
			require_once ABSPATH . 'wp-admin/includes/image.php';
		}

		$file_array = array(
			'name'     => $filename,
			'tmp_name' => $temp_path,
		);

		$media_id = null;

		try {
			$media_id = media_handle_sideload( $file_array, $post_id );
		} finally {
			if ( file_exists( $temp_path ) ) {
				wp_delete_file( $temp_path );
			}
		}

		if ( is_wp_error( $media_id ) ) {
			return new \WP_Error(
				'seovaultai_sideload_failed',
				__( 'Failed to create media attachment.', 'seovault-ai' ),
				array( 'status' => 500 )
			);
		}

		// Set alt text if non-empty.
		if ( '' !== $sanitized_alt ) {
			update_post_meta( $media_id, '_wp_attachment_image_alt', $sanitized_alt );
		}
		if ( '' !== $source_key ) {
			update_post_meta( $media_id, '_seovaultai_source_image_key', $source_key );
		}
		update_post_meta( $media_id, '_seovault_source_hash', hash( 'sha256', $decoded ) );

		return self::build_media_upload_response( $media_id, $sanitized_alt );
	}

	private static function build_media_upload_response( $media_id, $alt_text ) {
		$attachment_url  = wp_get_attachment_url( $media_id );
		$attachment_meta = wp_get_attachment_metadata( $media_id );
		$mime_type       = get_post_mime_type( $media_id );

		return new \WP_REST_Response(
			array(
				'ok'        => true,
				'media_id'  => (int) $media_id,
				'url'       => (string) $attachment_url,
				'width'     => isset( $attachment_meta['width'] ) ? (int) $attachment_meta['width'] : 0,
				'height'    => isset( $attachment_meta['height'] ) ? (int) $attachment_meta['height'] : 0,
				'mime_type' => (string) $mime_type,
				'alt_text'  => (string) $alt_text,
			),
			200
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: IndexNow
	// -----------------------------------------------------------------------

	public static function indexnow_setup( $request ) {
		Logger::log( 'API indexnow_setup: entered' );
		try {
			$key = bin2hex( random_bytes( 16 ) );
		} catch ( \Exception $exception ) {
			Logger::error( 'API indexnow_setup: key generation failed' );
			return new \WP_Error( 'indexnow_key_failed', 'Could not generate IndexNow key.', array( 'status' => 500 ) );
		}

		$file_path = self::indexnow_key_path( $key );
		if ( '' === $file_path ) {
			Logger::error( 'API indexnow_setup: invalid key path' );
			return new \WP_Error( 'indexnow_key_failed', 'Could not generate IndexNow key.', array( 'status' => 500 ) );
		}

		$written = self::put_file_contents( $file_path, $key );
		if ( false === $written ) {
			Logger::error( 'API indexnow_setup: key file write failed' );
			return new \WP_Error( 'indexnow_write_failed', 'Could not write IndexNow key file to site root.', array( 'status' => 500 ) );
		}

		$old_key = Options::get_indexnow_key();
		if ( is_string( $old_key ) && '' !== $old_key && $old_key !== $key ) {
			$old_path = self::indexnow_key_path( $old_key );
			if ( '' !== $old_path && file_exists( $old_path ) ) {
				wp_delete_file( $old_path );
			}
		}

		Options::set_indexnow_key( $key );
		Logger::log( 'API indexnow_setup: success', array( 'key_location' => trailingslashit( home_url() ) . $key . '.txt' ) );

		return rest_ensure_response(
			array(
			'ok'           => true,
			'configured'   => true,
			'key'          => $key,
			'key_location' => trailingslashit( home_url() ) . $key . '.txt',
			'host'         => wp_parse_url( home_url(), PHP_URL_HOST ),
			)
		);
	}

	public static function indexnow_status( $request ) {
		$key = Options::get_indexnow_key();
		if ( ! is_string( $key ) || '' === $key ) {
			return rest_ensure_response(
				array(
				'ok'         => true,
				'configured' => false,
				)
			);
		}

		$file_path = self::indexnow_key_path( $key );

		return rest_ensure_response(
			array(
			'ok'           => true,
			'configured'   => true,
			'has_key'      => true,
			'key_location' => trailingslashit( home_url() ) . $key . '.txt',
			'file_exists'  => '' !== $file_path && file_exists( $file_path ),
			'host'         => wp_parse_url( home_url(), PHP_URL_HOST ),
			)
		);
	}

	public static function indexnow_submit( $request ) {
		$urls = $request->get_param( 'urls' );

		if ( empty( $urls ) ) {
			Logger::error( 'API indexnow_submit: no URLs provided' );
			return new \WP_Error( 'indexnow_no_urls', 'No URLs provided.', array( 'status' => 400 ) );
		}

		if ( is_string( $urls ) ) {
			$urls = array_filter( array_map( 'trim', explode( "\n", $urls ) ) );
		}

		if ( ! is_array( $urls ) || empty( $urls ) ) {
			Logger::error( 'API indexnow_submit: invalid URLs provided' );
			return new \WP_Error( 'indexnow_invalid_urls', 'Invalid URLs provided.', array( 'status' => 400 ) );
		}

		$urls = array_filter( array_map( 'esc_url_raw', $urls ) );

		if ( empty( $urls ) ) {
			Logger::error( 'API indexnow_submit: no valid URLs after sanitization' );
			return new \WP_Error( 'indexnow_no_valid_urls', 'No valid URLs provided.', array( 'status' => 400 ) );
		}

		Logger::log( 'API indexnow_submit: submitting', array( 'url_count' => count( $urls ) ) );

		$api       = \SEOvaultSEO\IndexNow\IndexNowApi::get_instance();
		$submitted = $api->submit( $urls, true );

		if ( $submitted ) {
			Logger::log(
				'API indexnow_submit: success',
				array(
				'url_count'     => count( $urls ),
				'response_code' => $api->get_response_code(),
				)
			);
			return rest_ensure_response(
				array(
				'ok'    => true,
				'count' => count( $urls ),
				'code'  => $api->get_response_code(),
				)
			);
		}

		Logger::error(
			'API indexnow_submit: failed',
			array(
			'url_count'     => count( $urls ),
			'response_code' => $api->get_response_code(),
			'error'         => $api->get_error(),
			)
		);
		return new \WP_Error(
			'indexnow_submit_failed',
			$api->get_error(),
			array(
			'status' => 200,
			'code'   => $api->get_response_code(),
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Sitemap Proxy
	// -----------------------------------------------------------------------

	public static function sitemap_proxy( $request ) {
		$path = $request->get_param( 'path' );
		$url  = home_url( $path );

		$response = wp_remote_get(
			$url,
			array(
			'timeout'   => 15,
			'sslverify' => true,
			)
		);

		if ( is_wp_error( $response ) ) {
			return new \WP_Error(
				'sitemap_fetch_failed',
				$response->get_error_message(),
				array( 'status' => 502 )
			);
		}

		$code = wp_remote_retrieve_response_code( $response );
		$body = wp_remote_retrieve_body( $response );

		if ( $code < 200 || $code >= 300 || empty( $body ) ) {
			return rest_ensure_response(
				array(
				'ok'      => false,
				'status'  => $code,
				'xmlText' => '',
				)
			);
		}

		return rest_ensure_response(
			array(
			'ok'      => true,
			'status'  => $code,
			'xmlText' => $body,
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: SEO Capabilities
	// -----------------------------------------------------------------------

	public static function seo_capabilities( $request ) {
		$providers = self::detect_seo_providers();
		$preferred = self::get_preferred_seo_provider( $providers );

		$response = array(
			'ok'  => true,
			'seo' => array(
				'providersDetected' => $providers,
				'preferredProvider' => $preferred,
				'supports'          => array(
					'title'             => true,
					'description'       => true,
					'focusKeyword'      => true,
					'secondaryKeywords' => true,
					'slug'              => true,
					'imageAlt'          => true,
					'aeoSnippet'        => true,
					'quickAnswerBlock'  => true,
				),
			),
		);

		if ( count( $providers ) > 1 ) {
			$label                      = ucwords( str_replace( '-', ' ', $preferred ) );
			$response['seo']['warning'] = 'Multiple SEO plugins detected. Using ' . $label . ' as preferred provider.';
		}

		return rest_ensure_response( $response );
	}

	private static function detect_seo_providers() {
		$override = \SEOvaultSEO\Compatibility\Detector::get_test_rest_providers_override();
		if ( null !== $override ) {
			return $override;
		}

		$providers = array();

		if ( defined( 'WPSEO_VERSION' ) || class_exists( 'WPSEO_Options' ) || function_exists( 'YoastSEO' ) || class_exists( 'Yoast\\WP\\SEO\\Main' ) ) {
			$providers[] = 'yoast';
		}

		if ( defined( 'RANK_MATH_VERSION' ) || class_exists( 'RankMath' ) || class_exists( 'RankMath\\Helper' ) ) {
			$providers[] = 'rank-math';
		}

		if ( defined( 'AIOSEO_VERSION' ) || class_exists( 'AIOSEO\\Plugin\\AIOSEO' ) || class_exists( 'All_in_One_SEO_Pack' ) ) {
			$providers[] = 'aioseo';
		}

		return $providers;
	}

	private static function get_preferred_seo_provider( array $providers ) {
		foreach ( array( 'rank-math', 'yoast', 'aioseo' ) as $candidate ) {
			if ( in_array( $candidate, $providers, true ) ) {
				return $candidate;
			}
		}
		return '';
	}

	// -----------------------------------------------------------------------
	// Endpoint: Inject Image Alt
	// -----------------------------------------------------------------------

	public static function inject_image_alt( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
			Logger::error( 'API inject_image_alt: post not found', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'not_found', 'Post not found.', array( 'status' => 404 ) );
		}

		$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
		if ( ! $thumbnail_id ) {
			Logger::log( 'API inject_image_alt: no featured image', array( 'post_id' => $post_id ) );
			return rest_ensure_response(
				array(
				'ok'      => false,
				'code'    => 'NO_FEATURED_IMAGE',
				'field'   => 'imageAlt',
				'postId'  => $post_id,
				'message' => 'This post has no featured image. Add a featured image first.',
				)
			);
		}

		$json = $request->get_json_params();
		if ( ! isset( $json['alt'] ) || ! is_string( $json['alt'] ) || '' === trim( $json['alt'] ) ) {
			Logger::error( 'API inject_image_alt: missing alt text', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'missing_alt', 'Alt text is required.', array( 'status' => 400 ) );
		}

		$alt = sanitize_text_field( trim( $json['alt'] ) );
		update_post_meta( $thumbnail_id, '_wp_attachment_image_alt', $alt );
		Logger::log(
			'API inject_image_alt: success',
			array(
			'post_id'  => $post_id,
			'media_id' => $thumbnail_id,
			)
		);

		return rest_ensure_response(
			array(
			'ok'      => true,
			'field'   => 'imageAlt',
			'postId'  => $post_id,
			'mediaId' => $thumbnail_id,
			'value'   => $alt,
			'message' => 'Featured image alt text updated.',
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Inject AEO Snippet
	// -----------------------------------------------------------------------

	public static function inject_aeo( $request ) {
		$post_id = (int) $request->get_param( 'id' );
		$post    = get_post( $post_id );

		if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
			Logger::error( 'API inject_aeo: post not found', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'not_found', 'Post not found.', array( 'status' => 404 ) );
		}

		$json = $request->get_json_params();
		if ( ! isset( $json['snippet'] ) || ! is_string( $json['snippet'] ) || '' === trim( $json['snippet'] ) ) {
			Logger::error( 'API inject_aeo: missing snippet', array( 'post_id' => $post_id ) );
			return new \WP_Error( 'missing_snippet', 'Snippet text is required.', array( 'status' => 400 ) );
		}

		$snippet = sanitize_textarea_field( trim( $json['snippet'] ) );
		$content = $post->post_content;

		$applied = QuickAnswerAeoInjector::apply(
			$content,
			$snippet,
			array(
				'source' => 'aeo',
			)
		);

		if ( empty( $applied['ok'] ) ) {
			Logger::log(
				'API inject_aeo: apply declined',
				array(
					'post_id' => $post_id,
					'code'    => isset( $applied['code'] ) ? $applied['code'] : '',
					'mode'    => isset( $applied['mode'] ) ? $applied['mode'] : '',
				)
			);
			return rest_ensure_response(
				array(
					'ok'          => false,
					'code'        => isset( $applied['code'] ) ? $applied['code'] : 'apply_failed',
					'field'       => 'aeoSnippet',
					'postId'      => $post_id,
					'mode'        => isset( $applied['mode'] ) ? $applied['mode'] : '',
					'deprecated'  => ! empty( $applied['deprecated'] ),
					'deprecation' => isset( $applied['deprecation'] ) ? $applied['deprecation'] : null,
					'message'     => isset( $applied['message'] ) ? $applied['message'] : 'Injection declined.',
				)
			);
		}

		$result = wp_update_post(
			array(
				'ID'           => $post_id,
				'post_content' => $applied['content'],
			),
			true
		);

		if ( is_wp_error( $result ) ) {
			Logger::error(
				'API inject_aeo: wp_update_post failed',
				array(
					'post_id'    => $post_id,
					'error_code' => $result->get_error_code(),
					'error_msg'  => $result->get_error_message(),
				)
			);
			return new \WP_Error( 'update_failed', $result->get_error_message(), array( 'status' => 500 ) );
		}

		Logger::log(
			'API inject_aeo: success',
			array(
				'post_id'    => $post_id,
				'mode'       => $applied['mode'],
				'updated'    => ! empty( $applied['updated'] ),
				'deprecated' => ! empty( $applied['deprecated'] ),
			)
		);

		return rest_ensure_response(
			array(
				'ok'          => true,
				'field'       => 'aeoSnippet',
				'postId'      => $post_id,
				'mode'        => $applied['mode'],
				'updated'     => ! empty( $applied['updated'] ),
				'deprecated'  => ! empty( $applied['deprecated'] ),
				'deprecation' => isset( $applied['deprecation'] ) ? $applied['deprecation'] : null,
				'message'     => $applied['message'],
			)
		);
	}

	// -----------------------------------------------------------------------
	// Endpoint: Internal Links (delegate to InternalLinks class)
	// -----------------------------------------------------------------------

	public static function internal_links_manifest( $request ) {
		return InternalLinks::manifest();
	}

	public static function internal_links_sync( $request ) {
		return InternalLinks::sync( $request );
	}

	// -----------------------------------------------------------------------
	// AI Visibility endpoints (delegate to AiVisibility classes)
	// -----------------------------------------------------------------------

	public static function ai_visibility_status( $request ) {
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => array(
				'llms'         => \SEOvaultSEO\AiVisibility\Llms::get_status(),
				'robots'       => \SEOvaultSEO\AiVisibility\Robots::get_status(),
				'schema'       => \SEOvaultSEO\AiVisibility\Schema::get_status(),
				'crawler_logs' => \SEOvaultSEO\AiVisibility\CrawlerLogs::get_status(),
			),
			)
		);
	}

	public static function llms_status( $request ) {
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => \SEOvaultSEO\AiVisibility\Llms::get_status(),
			)
		);
	}

	public static function llms_preview( $request ) {
		$params  = $request->get_json_params();
		$content = $params['content'] ?? '';
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\Llms::preview( $content ) );
	}

	public static function llms_publish( $request ) {
		$params  = $request->get_json_params();
		$content = $params['content'] ?? '';
		$full    = $params['full'] ?? false;
		Logger::log(
			'API llms_publish: entered',
			array(
			'full'           => (bool) $full,
			'content_length' => strlen( (string) $content ),
			)
		);
		$result = rest_ensure_response( \SEOvaultSEO\AiVisibility\Llms::publish( $content, $full ) );
		Logger::log( 'API llms_publish: success', array( 'full' => (bool) $full ) );
		return $result;
	}

	public static function robots_status( $request ) {
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => \SEOvaultSEO\AiVisibility\Robots::get_status(),
			)
		);
	}

	public static function robots_preview( $request ) {
		$params         = $request->get_json_params();
		$params         = is_array( $params ) ? $params : array();
		$policy         = $params['policy'] ?? '';
		$custom_content = $params['custom_content'] ?? '';
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\Robots::preview( $policy, $custom_content ) );
	}

	public static function robots_apply( $request ) {
		$params         = $request->get_json_params();
		$params         = is_array( $params ) ? $params : array();
		$raw_policy     = isset( $params['policy'] ) ? sanitize_text_field( (string) $params['policy'] ) : '';
		$policy         = \SEOvaultSEO\Utils\Sanitizer::sanitize_robots_policy( $raw_policy );
		$custom_content = $params['custom_content'] ?? '';

		// Reject unknown policy values instead of silently coercing to default.
		if ( '' !== $raw_policy && '' === $policy ) {
			return new \WP_Error(
				'invalid_robots_policy',
				'Invalid robots policy.',
				array( 'status' => 400 )
			);
		}

		Logger::log( 'API robots_apply: entered', array( 'policy' => $policy ) );
		$result = rest_ensure_response( \SEOvaultSEO\AiVisibility\Robots::apply( $policy, $custom_content ) );
		Logger::log( 'API robots_apply: success', array( 'policy' => $policy ) );
		return $result;
	}

	public static function robots_restore( $request ) {
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\Robots::restore() );
	}

	public static function schema_status( $request ) {
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => \SEOvaultSEO\AiVisibility\Schema::get_status(),
			)
		);
	}

	public static function schema_preview( $request ) {
		$params      = $request->get_json_params();
		$schema_json = $params['schema'] ?? '';
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\Schema::preview( $schema_json ) );
	}

	public static function schema_inject( $request ) {
		$params      = $request->get_json_params();
		$post_id     = $params['post_id'] ?? 0;
		$schema_json = $params['schema'] ?? '';
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\Schema::inject( $post_id, $schema_json ) );
	}

	public static function crawler_logs_status( $request ) {
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => \SEOvaultSEO\AiVisibility\CrawlerLogs::get_status(),
			)
		);
	}

	public static function crawler_logs_enable( $request ) {
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\CrawlerLogs::enable() );
	}

	public static function crawler_logs_disable( $request ) {
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\CrawlerLogs::disable() );
	}

	public static function crawler_logs_get( $request ) {
		$limit  = absint( $request->get_param( 'limit' ) );
		$offset = absint( $request->get_param( 'offset' ) );
		if ( $limit < 1 ) {
			$limit = 100;
		}
		$limit = min( $limit, 500 );
		return rest_ensure_response( \SEOvaultSEO\AiVisibility\CrawlerLogs::get_logs( $limit, $offset ) );
	}

	// -----------------------------------------------------------------------
	// Import endpoints (delegate to Importer classes)
	// -----------------------------------------------------------------------

	public static function import_summary( $request ) {
		$provider = \SEOvaultSEO\Utils\Sanitizer::sanitize_import_provider( $request->get_param( 'provider' ) );
		if ( '' === $provider ) {
			return new \WP_Error(
				'invalid_import_provider',
				'Invalid SEO import provider.',
				array( 'status' => 400 )
			);
		}
		return rest_ensure_response(
			array(
			'ok'   => true,
			'data' => \SEOvaultSEO\Importer\Importer::get_summary( $provider ),
			)
		);
	}

	public static function import_meta( $request ) {
		$params    = $request->get_json_params();
		$params    = is_array( $params ) ? $params : array();
		$provider  = \SEOvaultSEO\Utils\Sanitizer::sanitize_import_provider( $params['provider'] ?? null );
		if ( '' === $provider ) {
			return new \WP_Error(
				'invalid_import_provider',
				'Invalid SEO import provider.',
				array( 'status' => 400 )
			);
		}
		$overwrite = ! empty( $params['overwrite'] );
		$offset    = absint( $params['offset'] ?? 0 );
		$limit     = absint( $params['limit'] ?? \SEOvaultSEO\Importer\Importer::BATCH_SIZE );
		if ( $limit < 1 ) {
			$limit = \SEOvaultSEO\Importer\Importer::BATCH_SIZE;
		}
		$limit = min( $limit, 200 );
		Logger::log(
			'API import_meta: entered',
			array(
			'provider'  => $provider,
			'overwrite' => $overwrite,
			'offset'    => $offset,
			'limit'     => $limit,
			)
		);
		$result = rest_ensure_response( \SEOvaultSEO\Importer\Importer::import_all( $provider, $overwrite, $offset, $limit ) );
		Logger::log( 'API import_meta: completed', array( 'provider' => $provider ) );
		return $result;
	}
}
