<?php
/**
 * Livada SEO — REST API
 *
 * Exposes all plugin data and actions over the WordPress REST API under the
 * `livada-seo/v1` namespace.  Every route requires the `manage_options`
 * capability; unauthenticated or unprivileged requests receive a 403.
 *
 * Routes registered:
 *   GET    /settings
 *   PUT    /settings
 *   GET    /meta/(?P<id>\d+)
 *   PUT    /meta/(?P<id>\d+)
 *   PUT    /meta/bulk
 *   GET    /redirects
 *   POST   /redirects
 *   PUT    /redirects/(?P<id>\d+)
 *   DELETE /redirects/(?P<id>\d+)
 *   GET    /analysis/(?P<id>\d+)
 *   POST   /import/seopress
 *   GET    /dashboard
 *   POST   /sitemap/flush
 *   POST   /indexnow/ping
 *
 * @package Livada_SEO
 */

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

class Livada_SEO_REST_API {

	/** REST namespace. */
	const REST_NAMESPACE = 'livada-seo/v1';

	// ── Bootstrap ─────────────────────────────────────────────────────────────

	/**
	 * Hooks into `rest_api_init` to register all routes.
	 * Called once by Livada_SEO_Loader::init().
	 */
	public static function init(): void {
		add_action( 'rest_api_init', [ self::class, 'register_routes' ] );
	}

	// ── Route registration ────────────────────────────────────────────────────

	/**
	 * Registers all REST routes under the livada-seo/v1 namespace.
	 */
	public static function register_routes(): void {
		$ns  = self::REST_NAMESPACE;
		$perm = [ self::class, 'check_permission' ];

		// ── Settings ──────────────────────────────────────────────────────────
		register_rest_route( $ns, '/settings', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_settings' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'update_settings' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Post meta ─────────────────────────────────────────────────────────
		register_rest_route( $ns, '/meta/bulk', [
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'bulk_update_meta' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/meta/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_meta' ],
				'permission_callback' => $perm,
				'args'                => self::post_id_args(),
			],
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'update_meta' ],
				'permission_callback' => $perm,
				'args'                => self::post_id_args(),
			],
		] );

		// ── Redirects ─────────────────────────────────────────────────────────
		register_rest_route( $ns, '/redirects', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_redirects' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'create_redirect' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/redirects/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'update_redirect' ],
				'permission_callback' => $perm,
				'args'                => self::id_args(),
			],
			[
				'methods'             => WP_REST_Server::DELETABLE,
				'callback'            => [ self::class, 'delete_redirect' ],
				'permission_callback' => $perm,
				'args'                => self::id_args(),
			],
		] );

		// ── Analysis ──────────────────────────────────────────────────────────
		register_rest_route( $ns, '/analysis/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_analysis' ],
				'permission_callback' => $perm,
				'args'                => self::post_id_args(),
			],
		] );

		// ── Import ────────────────────────────────────────────────────────────
		register_rest_route( $ns, '/import/seopress', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'import_seopress' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/import/yoast', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'import_yoast' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Multi-location ────────────────────────────────────────────────────
		register_rest_route( $ns, '/locations', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'list_locations' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'replace_locations' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'add_location' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/locations/(?P<index>\d+)', [
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'update_location' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::DELETABLE,
				'callback'            => [ self::class, 'delete_location' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Schema preview ────────────────────────────────────────────────────
		register_rest_route( $ns, '/schema/preview/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'preview_schema' ],
				'permission_callback' => $perm,
			],
		] );

		// ── AI suggestions ────────────────────────────────────────────────────
		register_rest_route( $ns, '/ai/suggest/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'ai_suggest' ],
				'permission_callback' => $perm,
			],
		] );

		// ── WooCommerce ───────────────────────────────────────────────────────
		register_rest_route( $ns, '/woo/product/(?P<id>\d+)/schema', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_product_schema_preview' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Image SEO ─────────────────────────────────────────────────────────
		register_rest_route( $ns, '/images/missing-alt', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'list_images_missing_alt' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/images/(?P<id>\d+)/alt', [
			[
				'methods'             => WP_REST_Server::EDITABLE,
				'callback'            => [ self::class, 'set_image_alt' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/images/(?P<id>\d+)/ai-alt', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'ai_generate_image_alt' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Site Audit ────────────────────────────────────────────────────────
		register_rest_route( $ns, '/audit', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_audit' ],
				'permission_callback' => $perm,
			],
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'run_audit' ],
				'permission_callback' => $perm,
			],
		] );

		// ── 404 monitor ───────────────────────────────────────────────────────
		register_rest_route( $ns, '/404', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'list_404s' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/404/(?P<id>\d+)', [
			[
				'methods'             => WP_REST_Server::DELETABLE,
				'callback'            => [ self::class, 'delete_404' ],
				'permission_callback' => $perm,
				'args'                => self::id_args(),
			],
		] );

		register_rest_route( $ns, '/404/(?P<id>\d+)/redirect', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'convert_404_to_redirect' ],
				'permission_callback' => $perm,
				'args'                => self::id_args(),
			],
		] );

		// ── Dashboard ─────────────────────────────────────────────────────────
		register_rest_route( $ns, '/dashboard', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'get_dashboard' ],
				'permission_callback' => $perm,
			],
		] );

		// ── Sitemap ───────────────────────────────────────────────────────────
		register_rest_route( $ns, '/sitemap/flush', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'flush_sitemap' ],
				'permission_callback' => $perm,
			],
		] );

		register_rest_route( $ns, '/sitemap/diagnose', [
			[
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => [ self::class, 'diagnose_sitemap' ],
				'permission_callback' => $perm,
			],
		] );

		// ── IndexNow ──────────────────────────────────────────────────────────
		register_rest_route( $ns, '/indexnow/ping', [
			[
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => [ self::class, 'indexnow_ping' ],
				'permission_callback' => $perm,
			],
		] );
	}

	// ── Permission callback ───────────────────────────────────────────────────

	/**
	 * Checks that the current user has the `manage_options` capability.
	 *
	 * @return bool|WP_Error
	 */
	public static function check_permission() {
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}

		return new WP_Error(
			'rest_forbidden',
			__( 'You do not have permission to access this endpoint.', 'livada-seo' ),
			[ 'status' => 403 ]
		);
	}

	// ── Settings handlers ─────────────────────────────────────────────────────

	/**
	 * GET /settings
	 * Returns the full livada_seo_settings array.
	 *
	 * @return WP_REST_Response
	 */
	public static function get_settings( WP_REST_Request $request ): WP_REST_Response {
		return new WP_REST_Response( Livada_SEO_Loader::get_settings(), 200 );
	}

	/**
	 * PUT /settings
	 * Merges sanitized input into saved settings and persists to the DB.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_settings( WP_REST_Request $request ) {
		$body = $request->get_json_params();

		if ( ! is_array( $body ) || empty( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Request body must be a non-empty JSON object.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		// URL keys that require esc_url_raw() sanitization.
		$url_keys = [
			'og_default_image',
			'twitter_default_image',
			'canonical_base',
		];

		// Template fields that contain %variable% placeholders.
		// sanitize_text_field() strips any %XX where XX is valid hex (e.g. %ca in %category%).
		// Use wp_strip_all_tags() instead to preserve placeholders while removing HTML.
		$template_prefixes = [ 'title_', 'desc_' ];

		$sanitized = [];

		foreach ( $body as $key => $value ) {
			$key = sanitize_key( $key );

			if ( '' === $key ) {
				continue;
			}

			$is_template = false;
			foreach ( $template_prefixes as $prefix ) {
				if ( strncmp( $key, $prefix, strlen( $prefix ) ) === 0 ) {
					$is_template = true;
					break;
				}
			}

			// Keys whose array values are URLs (need esc_url_raw, not sanitize_text_field).
			$url_array_keys = [ 'schema_same_as' ];

			if ( is_array( $value ) ) {
				if ( in_array( $key, $url_array_keys, true ) ) {
					// Sanitize each entry as a URL and drop empty ones.
					$sanitized[ $key ] = array_values(
						array_filter( array_map( 'esc_url_raw', $value ) )
					);
				} else {
					// Shallow-sanitize array values as text fields.
					$sanitized[ $key ] = array_map( 'sanitize_text_field', $value );
				}
			} elseif ( in_array( $key, $url_keys, true ) ) {
				$sanitized[ $key ] = esc_url_raw( (string) $value );
			} elseif ( is_bool( $value ) || is_int( $value ) ) {
				$sanitized[ $key ] = $value;
			} elseif ( $is_template ) {
				// Preserve %variable% placeholders — only strip HTML tags.
				$sanitized[ $key ] = wp_strip_all_tags( (string) $value );
			} else {
				$sanitized[ $key ] = sanitize_text_field( (string) $value );
			}
		}

		// Load current saved settings (raw, not merged with defaults).
		$saved = get_option( 'livada_seo_settings', [] );
		if ( ! is_array( $saved ) ) {
			$saved = [];
		}

		$merged = array_merge( $saved, $sanitized );
		update_option( 'livada_seo_settings', $merged, false );

		return new WP_REST_Response( $merged, 200 );
	}

	// ── Meta handlers ─────────────────────────────────────────────────────────

	/**
	 * GET /meta/{id}
	 * Returns all _livada_seo_* meta for the given post.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_meta( WP_REST_Request $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		$post    = get_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			return new WP_Error(
				'post_not_found',
				__( 'Post not found.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		return new WP_REST_Response( self::build_meta_response( $post_id ), 200 );
	}

	/**
	 * PUT /meta/{id}
	 * Updates _livada_seo_* meta fields from request body.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_meta( WP_REST_Request $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		$post    = get_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			return new WP_Error(
				'post_not_found',
				__( 'Post not found.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		$body = $request->get_json_params();

		if ( ! is_array( $body ) || empty( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Request body must be a non-empty JSON object.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		self::save_meta_fields( $post_id, $body );

		// Re-run SEO analysis after meta update — the REST flow doesn't trigger
		// `save_post`, so without this call the score and per-check details
		// stored in `_livada_seo_score(_details)` would stay stale.
		if ( class_exists( 'Livada_SEO_Analysis' ) ) {
			Livada_SEO_Analysis::analyze( $post_id );
		}

		return new WP_REST_Response( self::build_meta_response( $post_id ), 200 );
	}

	/**
	 * PUT /meta/bulk
	 * Accepts an array of {post_id, title, description, ...} objects and updates
	 * each post's meta fields in a single request.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function bulk_update_meta( WP_REST_Request $request ) {
		$body = $request->get_json_params();

		if ( ! is_array( $body ) || empty( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Request body must be a non-empty JSON array.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$updated  = [];
		$skipped  = [];

		foreach ( $body as $item ) {
			if ( ! is_array( $item ) || empty( $item['post_id'] ) ) {
				continue;
			}

			$post_id = absint( $item['post_id'] );
			$post    = get_post( $post_id );

			if ( ! $post instanceof WP_Post ) {
				$skipped[] = $post_id;
				continue;
			}

			unset( $item['post_id'] );
			self::save_meta_fields( $post_id, $item );

			// Re-run SEO analysis (REST doesn't trigger save_post).
			if ( class_exists( 'Livada_SEO_Analysis' ) ) {
				Livada_SEO_Analysis::analyze( $post_id );
			}

			$updated[] = $post_id;
		}

		return new WP_REST_Response(
			[
				'updated' => $updated,
				'skipped' => $skipped,
			],
			200
		);
	}

	// ── Redirects handlers ────────────────────────────────────────────────────

	/**
	 * GET /redirects
	 * Returns a paginated list of redirects with optional search.
	 *
	 * Query params: page (int), per_page (int), search (string).
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_redirects( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Redirects module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$page     = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
		$per_page = max( 1, absint( $request->get_param( 'per_page' ) ?: 25 ) );
		$search   = sanitize_text_field( (string) ( $request->get_param( 'search' ) ?: '' ) );

		$result = Livada_SEO_Redirects::get_redirects_paginated( $page, $per_page, $search );

		return new WP_REST_Response(
			[
				'items'    => $result['items'],
				'total'    => $result['total'],
				'page'     => $page,
				'per_page' => $per_page,
			],
			200
		);
	}

	/**
	 * POST /redirects
	 * Creates a new redirect (source_url, target_url, type).
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function create_redirect( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Redirects module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$body = $request->get_json_params();

		$source_url = isset( $body['source_url'] ) ? esc_url_raw( (string) $body['source_url'] ) : '';
		$target_url = isset( $body['target_url'] ) ? esc_url_raw( (string) $body['target_url'] ) : '';
		$type       = isset( $body['type'] ) ? absint( $body['type'] ) : 301;

		if ( '' === $source_url || '' === $target_url ) {
			return new WP_Error(
				'missing_fields',
				__( 'Both source_url and target_url are required.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		if ( ! in_array( $type, [ 301, 302 ], true ) ) {
			$type = 301;
		}

		$id = Livada_SEO_Redirects::add_redirect( $source_url, $target_url, $type );

		if ( false === $id ) {
			return new WP_Error(
				'insert_failed',
				__( 'Could not create redirect. The source URL may already exist.', 'livada-seo' ),
				[ 'status' => 409 ]
			);
		}

		return new WP_REST_Response( [ 'id' => $id ], 201 );
	}

	/**
	 * PUT /redirects/{id}
	 * Updates an existing redirect.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_redirect( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Redirects module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$id   = absint( $request->get_param( 'id' ) );
		$body = $request->get_json_params();

		if ( ! is_array( $body ) || empty( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Request body must be a non-empty JSON object.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$data = [];

		if ( isset( $body['source_url'] ) ) {
			$data['source_url'] = esc_url_raw( (string) $body['source_url'] );
		}

		if ( isset( $body['target_url'] ) ) {
			$data['target_url'] = esc_url_raw( (string) $body['target_url'] );
		}

		if ( isset( $body['type'] ) ) {
			$type = absint( $body['type'] );
			$data['type'] = in_array( $type, [ 301, 302 ], true ) ? $type : 301;
		}

		$ok = Livada_SEO_Redirects::update_redirect( $id, $data );

		if ( ! $ok ) {
			return new WP_Error(
				'update_failed',
				__( 'Could not update redirect. The record may not exist.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		return new WP_REST_Response( [ 'updated' => true, 'id' => $id ], 200 );
	}

	/**
	 * DELETE /redirects/{id}
	 * Deletes a redirect by ID.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_redirect( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Redirects module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$id = absint( $request->get_param( 'id' ) );
		$ok = Livada_SEO_Redirects::delete_redirect( $id );

		if ( ! $ok ) {
			return new WP_Error(
				'delete_failed',
				__( 'Could not delete redirect. The record may not exist.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		return new WP_REST_Response( [ 'deleted' => true, 'id' => $id ], 200 );
	}

	// ── Analysis handler ──────────────────────────────────────────────────────

	/**
	 * GET /analysis/{id}
	 * Runs Livada_SEO_Analysis::analyze() and returns the score and details.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_analysis( WP_REST_Request $request ) {
		$post_id = absint( $request->get_param( 'id' ) );
		$post    = get_post( $post_id );

		if ( ! $post instanceof WP_Post ) {
			return new WP_Error(
				'post_not_found',
				__( 'Post not found.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		if ( ! class_exists( 'Livada_SEO_Analysis' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Analysis module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		// Run a fresh analysis and persist results.
		Livada_SEO_Analysis::analyze( $post_id );

		$score   = (int) get_post_meta( $post_id, '_livada_seo_score', true );
		$details = get_post_meta( $post_id, '_livada_seo_score_details', true );

		if ( ! is_array( $details ) ) {
			$details = [];
		}

		return new WP_REST_Response(
			[
				'post_id'       => $post_id,
				'score'         => $score,
				'score_details' => $details,
			],
			200
		);
	}

	// ── Import handler ────────────────────────────────────────────────────────

	/**
	 * POST /import/seopress
	 * Delegates to Livada_SEO_Import to start/run the SEOPress import.
	 *
	 * Accepts an optional `action_type` body param (start|batch|redirections).
	 * When omitted, defaults to "start" so a single call kicks off the process.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function import_seopress( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Import' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Import module is not available.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$body        = $request->get_json_params();
		$action_type = isset( $body['action_type'] )
			? sanitize_key( (string) $body['action_type'] )
			: 'start';

		if ( ! in_array( $action_type, [ 'start', 'batch', 'redirections' ], true ) ) {
			return new WP_Error(
				'invalid_action',
				__( 'action_type must be one of: start, batch, redirections.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		// Simulate the AJAX environment so handle_ajax() can dispatch correctly.
		// Instead we call the inner logic directly via a lightweight wrapper.
		$result = self::run_import_action( $action_type );

		return new WP_REST_Response( $result, 200 );
	}

	// ── Dashboard handler ─────────────────────────────────────────────────────

	/**
	 * GET /dashboard
	 * Returns plugin-wide SEO statistics.
	 *
	 * Stats:
	 *   avg_score                — average _livada_seo_score across all posts
	 *   missing_description_count — posts missing a non-empty _livada_seo_description
	 *   noindex_count            — posts with _livada_seo_noindex truthy
	 *   redirect_count           — total rows in the redirects table
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response
	 */
	public static function get_dashboard( WP_REST_Request $request ): WP_REST_Response {
		global $wpdb;

		// Average SEO score across published posts that have a score stored.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$avg_score = (float) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT AVG(CAST(pm.meta_value AS UNSIGNED))
				 FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE pm.meta_key = %s
				   AND p.post_status = 'publish'",
				'_livada_seo_score'
			)
		);

		// Count of published posts missing a meta description.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$missing_description_count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT p.ID)
				 FROM {$wpdb->posts} p
				 WHERE p.post_status = 'publish'
				   AND p.post_type NOT IN ('revision','attachment')
				   AND NOT EXISTS (
				       SELECT 1 FROM {$wpdb->postmeta} pm
				       WHERE pm.post_id = p.ID
				         AND pm.meta_key = %s
				         AND pm.meta_value != ''
				   )",
				'_livada_seo_description'
			)
		);

		// Count of published posts with noindex set to a truthy value.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$noindex_count = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(*)
				 FROM {$wpdb->postmeta} pm
				 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
				 WHERE pm.meta_key = %s
				   AND pm.meta_value NOT IN ('', '0', 'false', 'no')
				   AND p.post_status = 'publish'",
				'_livada_seo_noindex'
			)
		);

		// Total redirect count.
		$redirect_count = class_exists( 'Livada_SEO_Redirects' )
			? Livada_SEO_Redirects::count_redirects()
			: 0;

		return new WP_REST_Response(
			[
				'avg_score'                 => round( $avg_score, 1 ),
				'missing_description_count' => $missing_description_count,
				'noindex_count'             => $noindex_count,
				'redirect_count'            => $redirect_count,
			],
			200
		);
	}

	// ── Sitemap handler ───────────────────────────────────────────────────────

	/**
	 * POST /sitemap/flush
	 * Deletes all sitemap transients so the next request regenerates them.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response
	 */
	public static function flush_sitemap( WP_REST_Request $request ): WP_REST_Response {
		global $wpdb;

		// Delete all transients whose option_name matches the sitemap prefix.
		// Covers _transient_livada_seo_sitemap_* and their timeout counterparts.
		$prefix = 'livada_seo_sitemap_';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options}
				 WHERE option_name LIKE %s
				    OR option_name LIKE %s",
				$wpdb->esc_like( '_transient_' . $prefix ) . '%',
				$wpdb->esc_like( '_transient_timeout_' . $prefix ) . '%'
			)
		);

		return new WP_REST_Response(
			[
				'flushed'        => true,
				'deleted_rows'   => (int) $deleted,
			],
			200
		);
	}

	// ── IndexNow handler ──────────────────────────────────────────────────────

	/**
	 * POST /indexnow/ping
	 * Schedules an IndexNow ping for the given URL.
	 *
	 * Body: { "url": "https://example.com/my-post/" }
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function indexnow_ping( WP_REST_Request $request ) {
		$body = $request->get_json_params();
		$url  = isset( $body['url'] ) ? esc_url_raw( (string) $body['url'] ) : '';

		if ( '' === $url ) {
			return new WP_Error(
				'missing_url',
				__( 'A non-empty "url" field is required in the request body.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		wp_schedule_single_event( time(), 'livada_seo_indexnow_ping', [ $url ] );

		return new WP_REST_Response( [ 'scheduled' => true, 'url' => $url ], 200 );
	}

	// ── Yoast import handler ──────────────────────────────────────────────────

	/**
	 * POST /import/yoast
	 *
	 * Drives the batched Yoast SEO import. Same protocol as the SEOPress
	 * endpoint: pass `action_type` = start | batch | redirections.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function import_yoast( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Import_Yoast' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Yoast import module is not available.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$body        = $request->get_json_params();
		$action_type = isset( $body['action_type'] )
			? sanitize_key( (string) $body['action_type'] )
			: 'start';

		$result = Livada_SEO_Import_Yoast::dispatch( $action_type );

		if ( isset( $result['_error'] ) ) {
			return new WP_Error(
				'invalid_action',
				(string) $result['_error'],
				[ 'status' => 400 ]
			);
		}

		return new WP_REST_Response( $result, 200 );
	}

	// ── Sitemap diagnose handler ──────────────────────────────────────────────

	/**
	 * GET /sitemap/diagnose
	 *
	 * Returns a structured snapshot of the sitemap configuration:
	 *   - Configured post types (settings)
	 *   - Active deny-list (template CPTs auto-stripped)
	 *   - Effective list emitted in the sitemap index
	 *   - All public post types on the site with their state
	 *   - Suspicious slugs flagged by the heuristic
	 *
	 * Useful right after a Yoast → Livada migration to confirm that
	 * Elementor / JetEngine / ElementsKit template CPTs are not exposed.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function diagnose_sitemap( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Sitemap' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Sitemap module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$configured = Livada_SEO_Loader::get_setting( 'sitemap_post_types', [ 'post', 'page' ] );
		$configured = is_array( $configured ) ? $configured : [ 'post', 'page' ];

		$deny     = Livada_SEO_Sitemap::get_template_cpts();
		$emitted  = array_values( array_diff( $configured, $deny ) );
		$stripped = array_values( array_intersect( $configured, $deny ) );

		$registered = get_post_types( [ 'public' => true ], 'objects' );
		unset( $registered['attachment'] );

		$post_types = [];
		$suspicious = [];

		foreach ( $registered as $slug => $obj ) {
			$is_in_settings = in_array( $slug, $configured, true );
			$is_template    = in_array( $slug, $deny, true ) || self::looks_like_template_cpt( $slug );

			if ( $is_in_settings && ! in_array( $slug, $emitted, true ) ) {
				$status = 'blocked_by_deny_list';
			} elseif ( $is_in_settings ) {
				$status = 'in_sitemap';
			} elseif ( $is_template ) {
				$status = 'template_excluded';
			} else {
				$status = 'not_in_sitemap';
			}

			$counts = wp_count_posts( $slug );

			$post_types[] = [
				'slug'        => $slug,
				'label'       => isset( $obj->labels->name ) ? (string) $obj->labels->name : '',
				'count'       => isset( $counts->publish ) ? (int) $counts->publish : 0,
				'is_template' => $is_template,
				'in_sitemap'  => 'in_sitemap' === $status,
				'status'      => $status,
			];

			if (
				! in_array( $slug, $deny, true ) &&
				! in_array( $slug, [ 'post', 'page' ], true ) &&
				self::looks_like_template_cpt( $slug )
			) {
				$suspicious[] = $slug;
			}
		}

		return new WP_REST_Response(
			[
				'configured_post_types' => array_values( $configured ),
				'deny_list'             => array_values( $deny ),
				'emitted_post_types'    => $emitted,
				'stripped_from_settings' => $stripped,
				'public_post_types'     => $post_types,
				'suspicious'            => $suspicious,
			],
			200
		);
	}

	/**
	 * Heuristic match for slugs that smell like template / library CPTs.
	 * Mirrors the same rule used by the WP-CLI sitemap diagnose subcommand.
	 */
	private static function looks_like_template_cpt( string $slug ): bool {
		$slug = strtolower( $slug );

		$prefixes = [ 'jkit-', 'jet-', 'elementor', 'oceanwp_', 'astra-', 'tcb_', 'fl-', 'cartflows_', 'brizy-' ];
		foreach ( $prefixes as $p ) {
			if ( 0 === strpos( $slug, $p ) ) {
				return true;
			}
		}

		$contains = [ 'template', 'library', 'layout', 'partial', 'header', 'footer', 'popup', 'lightbox' ];
		foreach ( $contains as $c ) {
			if ( false !== strpos( $slug, $c ) ) {
				return true;
			}
		}

		return false;
	}

	// ── Multi-location handlers ───────────────────────────────────────────────

	/**
	 * Returns the list of business locations.
	 *
	 * @return WP_REST_Response
	 */
	public static function list_locations( WP_REST_Request $request ): WP_REST_Response {
		$locations = class_exists( 'Livada_SEO_Schema' )
			? Livada_SEO_Schema::get_locations()
			: (array) get_option( 'livada_seo_locations', [] );

		return new WP_REST_Response(
			[
				'count'     => count( $locations ),
				'locations' => array_values( $locations ),
			],
			200
		);
	}

	/**
	 * Replaces the entire locations array with the provided list.
	 * Body: { "locations": [ { name, type, address, ... }, ... ] }
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function replace_locations( WP_REST_Request $request ) {
		$body = $request->get_json_params();

		if ( ! isset( $body['locations'] ) || ! is_array( $body['locations'] ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Body must contain a "locations" array.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$clean = [];
		foreach ( $body['locations'] as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$normalised = self::normalise_location( $row );
			if ( null !== $normalised ) {
				$clean[] = $normalised;
			}
		}

		update_option( 'livada_seo_locations', $clean, false );

		return new WP_REST_Response(
			[ 'count' => count( $clean ), 'locations' => $clean ],
			200
		);
	}

	/**
	 * Appends a single location to the array.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function add_location( WP_REST_Request $request ) {
		$body = $request->get_json_params();
		if ( ! is_array( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Body must be a JSON object describing the location.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$normalised = self::normalise_location( $body );
		if ( null === $normalised ) {
			return new WP_Error(
				'missing_name',
				__( 'A non-empty "name" is required.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$existing = (array) get_option( 'livada_seo_locations', [] );
		$existing[] = $normalised;

		update_option( 'livada_seo_locations', $existing, false );

		return new WP_REST_Response(
			[
				'index'    => count( $existing ) - 1,
				'location' => $normalised,
			],
			201
		);
	}

	/**
	 * Updates a single location at the given index. Partial updates supported:
	 * only keys present in the body overwrite the stored values.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function update_location( WP_REST_Request $request ) {
		$index = (int) $request->get_param( 'index' );
		$body  = $request->get_json_params();

		$existing = (array) get_option( 'livada_seo_locations', [] );

		if ( ! isset( $existing[ $index ] ) || ! is_array( $existing[ $index ] ) ) {
			return new WP_Error(
				'not_found',
				__( 'Location index out of range.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		if ( ! is_array( $body ) ) {
			return new WP_Error(
				'invalid_data',
				__( 'Body must be a JSON object.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$merged     = array_merge( $existing[ $index ], $body );
		$normalised = self::normalise_location( $merged );

		if ( null === $normalised ) {
			return new WP_Error(
				'missing_name',
				__( 'Location must keep a non-empty "name".', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		$existing[ $index ] = $normalised;
		update_option( 'livada_seo_locations', $existing, false );

		return new WP_REST_Response(
			[ 'index' => $index, 'location' => $normalised ],
			200
		);
	}

	/**
	 * Deletes a single location at the given index. Subsequent locations
	 * shift down — keep that in mind for `_livada_seo_location_id` post meta
	 * still referencing higher indexes.
	 *
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_location( WP_REST_Request $request ) {
		$index    = (int) $request->get_param( 'index' );
		$existing = (array) get_option( 'livada_seo_locations', [] );

		if ( ! isset( $existing[ $index ] ) ) {
			return new WP_Error(
				'not_found',
				__( 'Location index out of range.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		array_splice( $existing, $index, 1 );
		update_option( 'livada_seo_locations', $existing, false );

		return new WP_REST_Response(
			[ 'index' => $index, 'remaining' => count( $existing ) ],
			200
		);
	}

	/**
	 * Sanitises a single location array. Returns null when the row is invalid
	 * (missing or empty name). Mirrors the format expected by the schema
	 * builder.
	 *
	 * @param  array<string,mixed> $row
	 * @return array<string,mixed>|null
	 */
	private static function normalise_location( array $row ): ?array {
		$name = sanitize_text_field( (string) ( $row['name'] ?? '' ) );
		if ( '' === $name ) {
			return null;
		}

		$allowed_types = [
			'LocalBusiness', 'Organization',
			'Campground', 'LodgingBusiness', 'Hotel', 'BedAndBreakfast', 'Resort', 'Hostel', 'Motel',
			'Restaurant', 'Store', 'TouristAttraction',
		];

		$type = sanitize_text_field( (string) ( $row['type'] ?? 'LocalBusiness' ) );
		if ( ! in_array( $type, $allowed_types, true ) ) {
			$type = 'LocalBusiness';
		}

		$pets = '';
		if ( isset( $row['pets_allowed'] ) && '' !== $row['pets_allowed'] ) {
			$pets = filter_var( $row['pets_allowed'], FILTER_VALIDATE_BOOLEAN ) ? '1' : '0';
		}

		$amenities = [];
		if ( isset( $row['amenities'] ) && is_array( $row['amenities'] ) ) {
			foreach ( $row['amenities'] as $a ) {
				if ( is_string( $a ) ) {
					$amenities[] = [ 'name' => sanitize_text_field( $a ), 'value' => '1' ];
				} elseif ( is_array( $a ) ) {
					$an = sanitize_text_field( (string) ( $a['name'] ?? '' ) );
					if ( '' === $an ) {
						continue;
					}
					$av = isset( $a['value'] ) && filter_var( $a['value'], FILTER_VALIDATE_BOOLEAN ) ? '1' : '0';
					$amenities[] = [ 'name' => $an, 'value' => $av ];
				}
			}
		}

		$same_as = [];
		if ( isset( $row['same_as'] ) && is_array( $row['same_as'] ) ) {
			foreach ( $row['same_as'] as $url ) {
				$url = esc_url_raw( (string) $url );
				if ( '' !== $url ) {
					$same_as[] = $url;
				}
			}
		}

		$services = [];
		if ( isset( $row['services'] ) && is_array( $row['services'] ) ) {
			foreach ( $row['services'] as $svc ) {
				if ( is_string( $svc ) ) {
					$services[] = [ 'name' => sanitize_text_field( $svc ), 'description' => '' ];
				} elseif ( is_array( $svc ) ) {
					$sn = sanitize_text_field( (string) ( $svc['name'] ?? '' ) );
					if ( '' === $sn ) {
						continue;
					}
					$services[] = [
						'name'        => $sn,
						'description' => sanitize_textarea_field( (string) ( $svc['description'] ?? '' ) ),
					];
				}
			}
		}

		return [
			'name'             => $name,
			'type'             => $type,
			'address'          => sanitize_text_field( (string) ( $row['address'] ?? '' ) ),
			'city'             => sanitize_text_field( (string) ( $row['city'] ?? '' ) ),
			'postal'           => sanitize_text_field( (string) ( $row['postal'] ?? '' ) ),
			'country'          => substr( strtoupper( preg_replace( '/[^A-Za-z]/', '', (string) ( $row['country'] ?? 'FR' ) ) ), 0, 2 ),
			'phone'            => sanitize_text_field( (string) ( $row['phone'] ?? '' ) ),
			'email'            => sanitize_email( (string) ( $row['email'] ?? '' ) ),
			'url'              => esc_url_raw( (string) ( $row['url'] ?? '' ) ),
			'logo'             => esc_url_raw( (string) ( $row['logo'] ?? '' ) ),
			'geo_lat'          => sanitize_text_field( (string) ( $row['geo_lat'] ?? '' ) ),
			'geo_lng'          => sanitize_text_field( (string) ( $row['geo_lng'] ?? '' ) ),
			'price_range'      => sanitize_text_field( (string) ( $row['price_range'] ?? '' ) ),
			'star_rating'      => sanitize_text_field( (string) ( $row['star_rating'] ?? '' ) ),
			'number_of_rooms'  => sanitize_text_field( (string) ( $row['number_of_rooms'] ?? '' ) ),
			'pets_allowed'     => $pets,
			'checkin_time'     => sanitize_text_field( (string) ( $row['checkin_time'] ?? '' ) ),
			'checkout_time'    => sanitize_text_field( (string) ( $row['checkout_time'] ?? '' ) ),
			'rating_value'     => sanitize_text_field( (string) ( $row['rating_value'] ?? '' ) ),
			'review_count'     => sanitize_text_field( (string) ( $row['review_count'] ?? '' ) ),
			'amenities'        => $amenities,
			'same_as'          => $same_as,
			'services'         => $services,
			'opening_hours'    => isset( $row['opening_hours'] ) && is_array( $row['opening_hours'] ) ? $row['opening_hours'] : [],
		];
	}

	// ── Schema preview handler ────────────────────────────────────────────────

	/**
	 * GET /schema/preview/{id}
	 * Returns the full @graph that would be emitted on the front-end of the
	 * given post, without rendering anything.
	 */
	public static function preview_schema( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Schema' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Schema module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$id   = (int) $request->get_param( 'id' );
		$post = get_post( $id );
		if ( ! $post ) {
			return new WP_Error( 'not_found', __( 'Post not found.', 'livada-seo' ), [ 'status' => 404 ] );
		}

		return new WP_REST_Response( Livada_SEO_Schema::build_preview_graph( $id ), 200 );
	}

	// ── AI suggestion handler ─────────────────────────────────────────────────

	/**
	 * POST /ai/suggest/{id}
	 * Body: { "type": "title|description|keyword", "save": false }
	 *
	 * Returns the AI-generated suggestion. When `save:true`, also persists it
	 * to the matching `_livada_seo_*` post meta.
	 */
	public static function ai_suggest( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_AI' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'AI module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$post_id = (int) $request->get_param( 'id' );
		$body    = $request->get_json_params();
		$type    = isset( $body['type'] )  ? sanitize_key( (string) $body['type'] ) : '';
		$extra   = isset( $body['extra'] ) ? wp_kses_post( (string) $body['extra'] ) : '';
		$save    = ! empty( $body['save'] );

		$result = Livada_SEO_AI::suggest( $post_id, $type, $extra );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		if ( $save ) {
			$meta_map = [
				'title'       => '_livada_seo_title',
				'description' => '_livada_seo_description',
				'keyword'     => '_livada_seo_focus_keyword',
			];
			if ( isset( $meta_map[ $type ] ) ) {
				$sanitizer = 'description' === $type ? 'sanitize_textarea_field' : 'sanitize_text_field';
				update_post_meta( $post_id, $meta_map[ $type ], $sanitizer( (string) $result ) );
			}
		}

		return new WP_REST_Response(
			[
				'post_id' => $post_id,
				'type'    => $type,
				'value'   => (string) $result,
				'saved'   => $save,
			],
			200
		);
	}

	// ── WooCommerce handlers ──────────────────────────────────────────────────

	/**
	 * GET /woo/product/{id}/schema
	 *
	 * Returns the JSON-LD Product node Livada SEO would emit for the given
	 * Woo product, without rendering the frontend page. Useful for previews.
	 */
	public static function get_product_schema_preview( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_WooCommerce' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'WooCommerce module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$id   = (int) $request->get_param( 'id' );
		$node = Livada_SEO_WooCommerce::build_product_node( $id );

		if ( null === $node ) {
			return new WP_Error(
				'not_found',
				__( 'Product not found or WooCommerce inactive.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		return new WP_REST_Response( $node, 200 );
	}

	// ── Image SEO handlers ────────────────────────────────────────────────────

	/**
	 * GET /images/missing-alt
	 * Returns paginated attachments lacking alt-text.
	 */
	public static function list_images_missing_alt( WP_REST_Request $request ): WP_REST_Response {
		if ( ! class_exists( 'Livada_SEO_Image_Bulk' ) ) {
			return new WP_REST_Response( [ 'items' => [], 'total' => 0, 'pages' => 0 ], 200 );
		}

		$page     = max( 1, (int) $request->get_param( 'page' ) ?: 1 );
		$per_page = max( 1, min( 200, (int) $request->get_param( 'per_page' ) ?: 50 ) );
		$search   = sanitize_text_field( (string) $request->get_param( 'search' ) );

		return new WP_REST_Response(
			Livada_SEO_Image_Bulk::get_attachments_missing_alt( $page, $per_page, $search ),
			200
		);
	}

	/**
	 * PUT /images/{id}/alt
	 * Body: { "alt": "..." }
	 */
	public static function set_image_alt( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Image_Bulk' ) ) {
			return new WP_Error( 'module_unavailable', __( 'Image bulk module is not active.', 'livada-seo' ), [ 'status' => 503 ] );
		}

		$id   = (int) $request->get_param( 'id' );
		$body = $request->get_json_params();
		$alt  = isset( $body['alt'] ) ? (string) $body['alt'] : '';

		$result = Livada_SEO_Image_Bulk::set_alt( $id, $alt );
		if ( is_wp_error( $result ) ) {
			return $result;
		}

		return new WP_REST_Response( [ 'id' => $id, 'alt' => sanitize_text_field( $alt ) ], 200 );
	}

	/**
	 * POST /images/{id}/ai-alt
	 * Body: { "context": "optional sentence", "save": false }
	 *
	 * Returns the generated alt; when `save:true` is passed, also persists it.
	 */
	public static function ai_generate_image_alt( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Image_Bulk' ) ) {
			return new WP_Error( 'module_unavailable', __( 'Image bulk module is not active.', 'livada-seo' ), [ 'status' => 503 ] );
		}

		$id      = (int) $request->get_param( 'id' );
		$body    = $request->get_json_params();
		$context = isset( $body['context'] ) ? sanitize_textarea_field( (string) $body['context'] ) : '';
		$save    = ! empty( $body['save'] );

		$alt = Livada_SEO_Image_Bulk::generate_alt_with_ai( $id, $context );
		if ( is_wp_error( $alt ) ) {
			return $alt;
		}

		if ( $save ) {
			$saved = Livada_SEO_Image_Bulk::set_alt( $id, (string) $alt );
			if ( is_wp_error( $saved ) ) {
				return $saved;
			}
		}

		return new WP_REST_Response( [ 'id' => $id, 'alt' => $alt, 'saved' => $save ], 200 );
	}

	// ── Site Audit handlers ───────────────────────────────────────────────────

	/**
	 * GET /audit
	 *
	 * Returns the cached audit payload (summary + site-wide checks + per-post
	 * results). Triggers a fresh run on cache miss. Pass `?force=1` to ignore
	 * the cache and re-run.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function get_audit( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Site_Audit' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Site Audit module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		$force = filter_var( $request->get_param( 'force' ), FILTER_VALIDATE_BOOLEAN );
		return new WP_REST_Response( Livada_SEO_Site_Audit::get_results( (bool) $force ), 200 );
	}

	/**
	 * POST /audit
	 *
	 * Forces a fresh audit run, refreshes the cache, returns the new payload.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function run_audit( WP_REST_Request $request ) {
		if ( ! class_exists( 'Livada_SEO_Site_Audit' ) ) {
			return new WP_Error(
				'module_unavailable',
				__( 'Site Audit module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		return new WP_REST_Response( Livada_SEO_Site_Audit::run_audit( true ), 200 );
	}

	// ── 404 monitor handlers ──────────────────────────────────────────────────

	/**
	 * GET /404
	 *
	 * Lists logged 404 hits ordered by frequency. Supports `per_page` (1–500)
	 * and `min_hits` filters via query string.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response
	 */
	public static function list_404s( WP_REST_Request $request ): WP_REST_Response {
		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_404_log';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( '' === $exists ) {
			return new WP_REST_Response( [ 'items' => [], 'total' => 0 ], 200 );
		}

		$per_page = max( 1, min( 500, (int) $request->get_param( 'per_page' ) ?: 100 ) );
		$min_hits = max( 1, (int) $request->get_param( 'min_hits' ) ?: 1 );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$total = (int) $wpdb->get_var(
			$wpdb->prepare( "SELECT COUNT(*) FROM {$table} WHERE hits >= %d", $min_hits )
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, path, hits, last_seen, referer, user_agent
				 FROM {$table}
				 WHERE hits >= %d
				 ORDER BY hits DESC, last_seen DESC
				 LIMIT %d",
				$min_hits,
				$per_page
			)
		);

		return new WP_REST_Response(
			[
				'items' => $rows ?: [],
				'total' => $total,
			],
			200
		);
	}

	/**
	 * DELETE /404/{id}
	 *
	 * Removes a single 404 row.
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function delete_404( WP_REST_Request $request ) {
		$id = absint( $request->get_param( 'id' ) );
		if ( $id <= 0 ) {
			return new WP_Error( 'invalid_id', __( 'Invalid id.', 'livada-seo' ), [ 'status' => 400 ] );
		}

		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_404_log';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->delete( $table, [ 'id' => $id ], [ '%d' ] );

		return new WP_REST_Response( [ 'deleted' => true, 'id' => $id ], 200 );
	}

	/**
	 * POST /404/{id}/redirect
	 *
	 * Converts a logged 404 into a 301 redirect to the given target URL,
	 * then removes the 404 row. Body: { "target": "https://...", "type": 301 }
	 *
	 * @param  WP_REST_Request $request
	 * @return WP_REST_Response|WP_Error
	 */
	public static function convert_404_to_redirect( WP_REST_Request $request ) {
		$id   = absint( $request->get_param( 'id' ) );
		$body = $request->get_json_params();

		$target = isset( $body['target'] ) ? esc_url_raw( (string) $body['target'] ) : '';
		$type   = isset( $body['type'] )   ? (int) $body['type'] : 301;

		if ( $id <= 0 || '' === $target ) {
			return new WP_Error(
				'missing_data',
				__( 'A valid id and a non-empty "target" URL are required.', 'livada-seo' ),
				[ 'status' => 400 ]
			);
		}

		if ( ! in_array( $type, [ 301, 302 ], true ) ) {
			$type = 301;
		}

		if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
			return new WP_Error(
				'redirects_unavailable',
				__( 'Redirects module is not active.', 'livada-seo' ),
				[ 'status' => 503 ]
			);
		}

		global $wpdb;
		$table = $wpdb->prefix . 'livada_seo_404_log';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$row = $wpdb->get_row(
			$wpdb->prepare( "SELECT path FROM {$table} WHERE id = %d", $id )
		);

		if ( ! $row ) {
			return new WP_Error(
				'not_found',
				__( '404 row not found.', 'livada-seo' ),
				[ 'status' => 404 ]
			);
		}

		$redirect_id = Livada_SEO_Redirects::add_redirect( (string) $row->path, $target, $type );

		if ( false === $redirect_id ) {
			return new WP_Error(
				'redirect_create_failed',
				__( 'Could not create redirect (duplicate source?).', 'livada-seo' ),
				[ 'status' => 409 ]
			);
		}

		// Clean up the 404 row on success.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		$wpdb->delete( $table, [ 'id' => $id ], [ '%d' ] );

		return new WP_REST_Response(
			[
				'redirect_id' => (int) $redirect_id,
				'source'      => (string) $row->path,
				'target'      => $target,
				'type'        => $type,
			],
			201
		);
	}

	// ── Private helpers ───────────────────────────────────────────────────────

	/**
	 * Builds the standard meta response object for a post.
	 *
	 * @param  int $post_id
	 * @return array<string,mixed>
	 */
	private static function build_meta_response( int $post_id ): array {
		$score_details = get_post_meta( $post_id, '_livada_seo_score_details', true );

		if ( ! is_array( $score_details ) ) {
			$score_details = [];
		}

		$noindex  = get_post_meta( $post_id, '_livada_seo_noindex',  true );
		$nofollow = get_post_meta( $post_id, '_livada_seo_nofollow', true );

		$service_schema = get_post_meta( $post_id, '_livada_seo_service_schema', true );
		$faq_schema     = get_post_meta( $post_id, '_livada_seo_faq_schema',     true );

		return [
			'post_id'        => $post_id,
			'title'          => (string) get_post_meta( $post_id, '_livada_seo_title',         true ),
			'description'    => (string) get_post_meta( $post_id, '_livada_seo_description',   true ),
			'focus_keyword'  => (string) get_post_meta( $post_id, '_livada_seo_focus_keyword', true ),
			'noindex'        => self::meta_bool( $noindex ),
			'nofollow'       => self::meta_bool( $nofollow ),
			'canonical'      => (string) get_post_meta( $post_id, '_livada_seo_canonical',     true ),
			'og_image'       => (string) get_post_meta( $post_id, '_livada_seo_og_image',      true ),
			'service_schema' => self::meta_bool( $service_schema ),
			'faq_schema'     => self::meta_bool( $faq_schema ),
			'score'          => (int)    get_post_meta( $post_id, '_livada_seo_score',         true ),
			'score_details'  => $score_details,
		];
	}

	/**
	 * Saves supported meta fields from an input array to post meta.
	 *
	 * Known URL fields are sanitized with esc_url_raw(); all others with
	 * sanitize_text_field(). Boolean fields are stored as '1' or ''.
	 *
	 * @param int                  $post_id
	 * @param array<string,mixed>  $fields
	 */
	private static function save_meta_fields( int $post_id, array $fields ): void {
		$url_fields  = [ 'canonical', 'og_image' ];
		$bool_fields = [ 'noindex', 'nofollow', 'service_schema', 'faq_schema' ];

		$allowed = [
			'title'          => '_livada_seo_title',
			'description'    => '_livada_seo_description',
			'focus_keyword'  => '_livada_seo_focus_keyword',
			'noindex'        => '_livada_seo_noindex',
			'nofollow'       => '_livada_seo_nofollow',
			'canonical'      => '_livada_seo_canonical',
			'og_image'       => '_livada_seo_og_image',
			'service_schema' => '_livada_seo_service_schema',
			'faq_schema'     => '_livada_seo_faq_schema',
		];

		foreach ( $allowed as $field => $meta_key ) {
			if ( ! array_key_exists( $field, $fields ) ) {
				continue;
			}

			$raw = $fields[ $field ];

			if ( in_array( $field, $bool_fields, true ) ) {
				$value = ( $raw && 'false' !== $raw && '0' !== (string) $raw ) ? '1' : '';
			} elseif ( in_array( $field, $url_fields, true ) ) {
				$value = esc_url_raw( (string) $raw );
			} else {
				$value = sanitize_text_field( (string) $raw );
			}

			update_post_meta( $post_id, $meta_key, $value );
		}
	}

	/**
	 * Converts a stored meta value to a boolean for JSON output.
	 *
	 * @param  mixed $value Raw meta value.
	 * @return bool
	 */
	private static function meta_bool( $value ): bool {
		if ( '' === $value || false === $value || '0' === $value || 'false' === $value ) {
			return false;
		}

		return (bool) $value;
	}

	/**
	 * Runs an import action by calling into Livada_SEO_Import's private logic
	 * without going through the AJAX layer.
	 *
	 * Since handle_start/handle_batch/handle_redirections are private, we
	 * replicate a minimal shim that re-uses the public transient-based state.
	 *
	 * @param  string $action_type  start|batch|redirections
	 * @return array<string,mixed>
	 */
	private static function run_import_action( string $action_type ): array {
		// Temporarily set the POST vars expected by handle_ajax() and call it
		// via output buffering so we can capture the JSON response cleanly.
		// This avoids duplicating the batch/migration logic.

		// Backup and override global state.
		$original_post        = $_POST;   // phpcs:ignore WordPress.Security.NonceVerification
		$_POST['action_type'] = $action_type;

		// Suppress the nonce check inside handle_ajax() by injecting a filter.
		add_filter( 'nonce_user_logged_out', '__return_true' );

		// Use output buffering to intercept wp_send_json_success/error output.
		ob_start();

		// We must fake the AJAX nonce check. Since handle_ajax() calls
		// check_ajax_referer() which will wp_die() on failure, we instead
		// bypass it by calling the individual action handlers directly through
		// a dedicated REST-safe dispatch method.
		$result = self::dispatch_import( $action_type );

		ob_end_clean();

		remove_filter( 'nonce_user_logged_out', '__return_true' );
		$_POST = $original_post; // phpcs:ignore WordPress.Security.NonceVerification

		return $result;
	}

	/**
	 * Dispatches import actions without going through the AJAX nonce/auth layer.
	 *
	 * Replicates the three action handlers from Livada_SEO_Import using only
	 * public APIs (transients, WP_Query, post meta) so there is no coupling
	 * to private methods.
	 *
	 * @param  string $action_type  start|batch|redirections
	 * @return array<string,mixed>
	 */
	private static function dispatch_import( string $action_type ): array {
		$offset_key = 'livada_seo_import_offset';
		$total_key  = 'livada_seo_import_total';
		$batch_size = 50;

		switch ( $action_type ) {
			case 'start':
				set_transient( $offset_key, 0, HOUR_IN_SECONDS );

				$total = self::count_importable_posts();
				set_transient( $total_key, $total, HOUR_IN_SECONDS );

				return [
					'done'      => false,
					'processed' => 0,
					'total'     => $total,
					'offset'    => 0,
					'warnings'  => [],
				];

			case 'batch':
				$offset   = (int) get_transient( $offset_key );
				$total    = (int) get_transient( $total_key );

				if ( 0 === $total ) {
					$total = self::count_importable_posts();
					set_transient( $total_key, $total, HOUR_IN_SECONDS );
				}

				global $wpdb;

				$meta_keys        = array_keys( Livada_SEO_Import::META_MAP );
				$keys_placeholder = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$posts = $wpdb->get_results(
					$wpdb->prepare(
						// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
						"SELECT DISTINCT p.ID
						 FROM {$wpdb->posts} p
						 WHERE p.post_status NOT IN ('auto-draft','trash')
						   AND p.post_type NOT IN ('revision','attachment')
						   AND EXISTS (
						       SELECT 1 FROM {$wpdb->postmeta} pm
						       WHERE pm.post_id = p.ID
						         AND pm.meta_key IN ({$keys_placeholder})
						   )
						 ORDER BY p.ID ASC
						 LIMIT %d OFFSET %d",
						...array_merge( $meta_keys, [ $batch_size, $offset ] )
					)
				) ?: [];

				$warnings = [];

				foreach ( $posts as $row ) {
					$result = self::migrate_post_meta_via_import( (int) $row->ID );
					foreach ( $result as $w ) {
						$warnings[] = sprintf( 'Post %d: %s', (int) $row->ID, $w );
					}
				}

				$batch_count = count( $posts );
				$new_offset  = $offset + $batch_count;

				set_transient( $offset_key, $new_offset, HOUR_IN_SECONDS );

				$done = ( $batch_count < $batch_size ) || ( $new_offset >= $total );

				if ( $done ) {
					delete_transient( $offset_key );
					delete_transient( $total_key );
				}

				return [
					'done'      => $done,
					'processed' => $new_offset,
					'total'     => $total,
					'offset'    => $new_offset,
					'warnings'  => $warnings,
				];

			case 'redirections':
				$warnings = [];
				$imported = 0;
				$skipped  = 0;

				if ( ! class_exists( 'Livada_SEO_Redirects' ) ) {
					return [
						'done'     => true,
						'imported' => 0,
						'skipped'  => 0,
						'warnings' => [ __( 'Redirects module unavailable.', 'livada-seo' ) ],
					];
				}

				$query = new WP_Query( [
					'post_type'      => 'seopress_404',
					'post_status'    => 'publish',
					'posts_per_page' => -1,
					'fields'         => 'ids',
					'no_found_rows'  => true,
				] );

				foreach ( $query->posts as $post_id ) {
					$post_id = (int) $post_id;
					$post    = get_post( $post_id );

					if ( ! $post ) {
						continue;
					}

					$source     = '/' . ltrim( $post->post_name, '/' );
					$raw_target = get_post_meta( $post_id, '_seopress_redirections_value', true );
					$raw_type   = get_post_meta( $post_id, '_seopress_redirections_type', true );
					$type       = in_array( (int) $raw_type, [ 301, 302 ], true ) ? (int) $raw_type : 301;

					if ( empty( $raw_target ) ) {
						$skipped++;
						$warnings[] = sprintf( 'Redirection post %d: missing target URL, skipped.', $post_id );
						continue;
					}

					$target = esc_url_raw( $raw_target );

					if ( empty( $target ) ) {
						$skipped++;
						$warnings[] = sprintf( 'Redirection post %d: invalid target URL, skipped.', $post_id );
						continue;
					}

					$result = Livada_SEO_Redirects::add_redirect( $source, $target, $type );

					if ( false === $result ) {
						$skipped++;
						$warnings[] = sprintf( 'Redirection post %d: could not insert source "%s" (may already exist).', $post_id, $source );
					} else {
						$imported++;
					}
				}

				return [
					'done'     => true,
					'imported' => $imported,
					'skipped'  => $skipped,
					'warnings' => $warnings,
				];
		}

		return [ 'done' => false, 'warnings' => [ 'Unknown action_type.' ] ];
	}

	/**
	 * Migrates SEOPress meta for a single post to Livada SEO meta keys.
	 * Uses the META_MAP and URL_FIELDS constants from Livada_SEO_Import.
	 *
	 * @param  int      $post_id
	 * @return string[]  Warnings.
	 */
	private static function migrate_post_meta_via_import( int $post_id ): array {
		$warnings = [];

		foreach ( Livada_SEO_Import::META_MAP as $seopress_key => $livada_key ) {
			$raw = get_post_meta( $post_id, $seopress_key, true );

			if ( '' === $raw || false === $raw ) {
				continue;
			}

			$is_url = in_array( $seopress_key, Livada_SEO_Import::URL_FIELDS, true );

			if ( $is_url ) {
				$sanitized = esc_url_raw( (string) $raw );
				if ( '' === $sanitized ) {
					$warnings[] = sprintf( 'invalid %s skipped', str_replace( '_seopress_', '', $seopress_key ) );
					continue;
				}
			} else {
				$sanitized = sanitize_text_field( (string) $raw );
				if ( '' === $sanitized && '' !== (string) $raw ) {
					$warnings[] = sprintf( 'invalid %s skipped', str_replace( '_seopress_', '', $seopress_key ) );
					continue;
				}
			}

			update_post_meta( $post_id, $livada_key, $sanitized );
		}

		return $warnings;
	}

	/**
	 * Counts posts with at least one SEOPress meta key — used by the import dispatcher.
	 *
	 * @return int
	 */
	private static function count_importable_posts(): int {
		global $wpdb;

		$meta_keys        = array_keys( Livada_SEO_Import::META_MAP );
		$keys_placeholder = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery
		return (int) $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"SELECT COUNT(DISTINCT p.ID)
				 FROM {$wpdb->posts} p
				 WHERE p.post_status NOT IN ('auto-draft','trash')
				   AND p.post_type NOT IN ('revision','attachment')
				   AND EXISTS (
				       SELECT 1 FROM {$wpdb->postmeta} pm
				       WHERE pm.post_id = p.ID
				         AND pm.meta_key IN ({$keys_placeholder})
				   )",
				...$meta_keys
			)
		);
	}

	// ── Route arg definitions ─────────────────────────────────────────────────

	/**
	 * Returns route args definition for a generic `id` parameter.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private static function id_args(): array {
		return [
			'id' => [
				'required'          => true,
				'validate_callback' => static function ( $value ): bool {
					return is_numeric( $value ) && (int) $value > 0;
				},
				'sanitize_callback' => 'absint',
			],
		];
	}

	/**
	 * Returns route args definition for a `post_id` / `id` parameter that
	 * must resolve to an existing post.
	 *
	 * Validation that the post actually exists happens inside the callback to
	 * return a descriptive 404 rather than a generic 400.
	 *
	 * @return array<string,array<string,mixed>>
	 */
	private static function post_id_args(): array {
		return self::id_args();
	}
}
