<?php
/**
 * Google Integration API Controller.
 *
 * Handles REST endpoints for Google APIs: PageSpeed Insights / Core Web Vitals,
 * Search Console, GA4, and the Google Indexing API.
 *
 * @package Antimanual
 * @since   1.7.0
 */

namespace Antimanual\Api;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Antimanual\AIProvider;
use Antimanual\Seo\Google\GoogleAuthService;
use Antimanual\Seo\Google\PageSpeedClient;
use Antimanual\Seo\Google\SearchConsoleClient;
use Antimanual\Seo\Google\GA4Client;
use Antimanual\Seo\Google\IndexingApiClient;

/**
 * Google Integration REST API controller.
 *
 * Exposes PSI/CrUX, Search Console, GA4, and the Indexing API to the React
 * admin via dedicated /seo-agent/* routes. Each handler delegates to a thin
 * client class in Antimanual\Seo\Google.
 */
class GoogleIntegrationController {

	/**
	 * Constructor — registers Indexing API hooks for auto-submission.
	 */
	public function __construct() {
		// Auto-submit newly published / unpublished URLs to the Google Indexing API.
		add_action( 'transition_post_status', array( $this, 'maybe_submit_to_indexing_api' ), 30, 3 );
		add_action( 'atml_indexing_api_submit', array( $this, 'run_indexing_api_submission' ), 10, 2 );
	}

	/**
	 * Register all Google Integration REST routes.
	 *
	 * @param string $namespace The REST namespace.
	 */
	public function register_routes( string $namespace ): void {

		/* ---------- Credentials & Status ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/google/status',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'google_status' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/google/credentials',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'google_save_credentials' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/google/sites',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'google_list_sites' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/google/test-connection',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'google_test_connection' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 30,
			)
		);

		/* ---------- Core Web Vitals / PageSpeed Insights ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/cwv',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'cwv' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/cwv/fix-suggestions',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'cwv_fix_suggestions' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		/* ---------- Google Search Console ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/gsc/queries',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'gsc_queries' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 90,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/gsc/inspect',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'gsc_inspect' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 90,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/gsc/quick-wins',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'gsc_quick_wins' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 120,
			)
		);

		/* ---------- Google Analytics 4 ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/ga4/organic',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'ga4_organic' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 90,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/ga4/properties',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'ga4_properties' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 30,
			)
		);

		/* ---------- Opportunity Ranker ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/google/opportunity-ranker',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'opportunity_ranker' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 90,
			)
		);

		/* ---------- Google Indexing API ---------- */

		register_rest_route(
			$namespace,
			'/seo-agent/indexing/gate',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'indexing_gate' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 90,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/indexing/submit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'indexing_submit' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/indexing/status',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'indexing_status' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
				'timeout'             => 60,
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/indexing/log',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'indexing_log' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
			)
		);

		register_rest_route(
			$namespace,
			'/seo-agent/indexing/auto-submit',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'indexing_set_auto_submit' ),
				'permission_callback' => fn() => current_user_can( 'manage_options' ),
			)
		);
	}

	/* ====================================================================
	 *  Credentials & Auth
	 * ==================================================================== */

	/**
	 * Get the configured-credentials status for the Google Integrations.
	 *
	 * @return \WP_REST_Response
	 */
	public function google_status() {
		$status = GoogleAuthService::instance()->get_status();
		$status['auto_submit_indexing'] = ( new IndexingApiClient() )->is_auto_submit_enabled();

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

	/**
	 * Save the PSI API key and / or service-account JSON.
	 *
	 * Body params:
	 *   - psi_api_key: string (optional). Empty string clears.
	 *   - service_account_json: string (optional). Empty string clears.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function google_save_credentials( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();
		$auth    = GoogleAuthService::instance();

		$messages = array();

		if ( array_key_exists( 'psi_api_key', $payload ) ) {
			$key = is_string( $payload['psi_api_key'] ) ? sanitize_text_field( $payload['psi_api_key'] ) : '';
			$auth->save_psi_key( $key );
			$messages[] = '' === $key
				? __( 'PageSpeed API key removed.', 'antimanual' )
				: __( 'PageSpeed API key saved.', 'antimanual' );
		}

		if ( array_key_exists( 'service_account_json', $payload ) ) {
			$json   = is_string( $payload['service_account_json'] ) ? $payload['service_account_json'] : '';
			$result = $auth->save_service_account_json( $json );
			if ( is_wp_error( $result ) ) {
				return new \WP_REST_Response(
					array(
						'success' => false,
						'message' => $result->get_error_message(),
					),
					400
				);
			}
			$messages[] = '' === trim( $json )
				? __( 'Google service account removed.', 'antimanual' )
				: __( 'Google service account saved.', 'antimanual' );
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'status'   => $auth->get_status(),
					'messages' => $messages,
				),
			)
		);
	}

	/**
	 * List GSC properties the configured service account can read.
	 *
	 * @return \WP_REST_Response
	 */
	public function google_list_sites() {
		$result = ( new SearchConsoleClient() )->list_sites();
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}
		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array( 'sites' => $result ),
			)
		);
	}

	/**
	 * Test the configured Google credentials (PSI key and service account).
	 *
	 * Tests PSI by fetching site status, tests SA by listing GSC sites.
	 * Returns per-credential success/error without exposing key values.
	 *
	 * @return \WP_REST_Response
	 */
	public function google_test_connection() {
		$results = array();

		$auth = GoogleAuthService::instance();

		// Test PSI key: attempt a minimal PSI call on home_url.
		if ( $auth->has_psi_key() ) {
			$psi_result = ( new PageSpeedClient() )->analyze( home_url( '/' ), 'mobile' );
			if ( is_wp_error( $psi_result ) ) {
				$results['psi'] = array(
					'ok'      => false,
					'message' => $psi_result->get_error_message(),
				);
			} else {
				$results['psi'] = array(
					'ok'      => true,
					'message' => __( 'PageSpeed Insights key is working.', 'antimanual' ),
				);
			}
		} else {
			$results['psi'] = array(
				'ok'      => null,
				'message' => __( 'No PSI key configured.', 'antimanual' ),
			);
		}

		// Test service account: list GSC sites.
		if ( $auth->has_service_account() ) {
			$sc_result = ( new SearchConsoleClient() )->list_sites();
			if ( is_wp_error( $sc_result ) ) {
				$results['service_account'] = array(
					'ok'      => false,
					'message' => $sc_result->get_error_message(),
				);
			} else {
				$count                      = count( $sc_result );
				$results['service_account'] = array(
					'ok'      => true,
					/* translators: %d: number of Search Console properties found. */
					'message' => sprintf( _n( 'Service account authenticated. %d property found.', 'Service account authenticated. %d properties found.', $count, 'antimanual' ), $count ),
				);
			}
		} else {
			$results['service_account'] = array(
				'ok'      => null,
				'message' => __( 'No service account configured.', 'antimanual' ),
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $results,
			)
		);
	}

	/* ====================================================================
	 *  Core Web Vitals / PageSpeed Insights
	 * ==================================================================== */

	/**
	 * Run a PageSpeed Insights / CrUX analysis.
	 *
	 * Body params:
	 *   - url: string (required).
	 *   - strategy: 'mobile' | 'desktop' (default 'mobile').
	 *   - force_refresh: bool.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function cwv( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$url      = isset( $payload['url'] ) ? esc_url_raw( (string) $payload['url'] ) : '';
		$strategy = isset( $payload['strategy'] ) ? sanitize_key( (string) $payload['strategy'] ) : 'mobile';
		$force    = ! empty( $payload['force_refresh'] );

		$result = ( new PageSpeedClient() )->analyze( $url, $strategy, $force );
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Use AI to translate PSI opportunities into WordPress-specific fix suggestions.
	 *
	 * Body params:
	 *   - url:               string (required) The page that was analysed.
	 *   - strategy:          string "mobile"|"desktop" (default "mobile").
	 *   - performance_score: int|null Lighthouse score 0–100.
	 *   - opportunities:     array  CwvOpportunity objects from the PSI response.
	 *   - lab:               array  Lab metric values (lcp, inp, cls, fcp, ttfb, tbt).
	 *   - force_refresh:     bool.
	 *
	 * Returns:
	 *   { suggestions: CwvFixSuggestion[], url, strategy, performance_score,
	 *     generated_at, cached }
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function cwv_fix_suggestions( $request ) {
		$payload      = json_decode( $request->get_body(), true );
		$payload      = is_array( $payload ) ? $payload : array();

		$url          = isset( $payload['url'] ) ? esc_url_raw( (string) $payload['url'] ) : '';
		$strategy     = isset( $payload['strategy'] ) ? sanitize_key( (string) $payload['strategy'] ) : 'mobile';
		$perf_score   = isset( $payload['performance_score'] ) && is_numeric( $payload['performance_score'] )
			? (int) $payload['performance_score']
			: null;
		$opportunities = isset( $payload['opportunities'] ) && is_array( $payload['opportunities'] )
			? $payload['opportunities']
			: array();
		$lab          = isset( $payload['lab'] ) && is_array( $payload['lab'] ) ? $payload['lab'] : array();
		$force        = ! empty( $payload['force_refresh'] );

		if ( empty( $url ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'No URL provided.', 'antimanual' ),
				),
				400
			);
		}

		if ( empty( $opportunities ) ) {
			$result = array(
				'url'               => $url,
				'strategy'          => $strategy,
				'performance_score' => $perf_score,
				'suggestions'       => array(),
				'generated_at'      => gmdate( 'c' ),
				'cached'            => false,
			);
			return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
		}

		$opp_ids   = array_map( fn( $o ) => (string) ( $o['id'] ?? '' ), $opportunities );
		$cache_key = 'atml_cwv_fixes_' . md5( $url . '_' . $strategy . '_' . implode( ',', $opp_ids ) );

		if ( ! $force ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached && is_array( $cached ) ) {
				$cached['cached'] = true;
				return rest_ensure_response( array( 'success' => true, 'data' => $cached ) );
			}
		}

		$opps_for_prompt = array_slice( $opportunities, 0, 12 );
		$opp_lines       = array();
		foreach ( $opps_for_prompt as $opp ) {
			$id          = sanitize_key( (string) ( $opp['id'] ?? '' ) );
			$title       = sanitize_text_field( (string) ( $opp['title'] ?? '' ) );
			$description = sanitize_text_field( (string) ( $opp['description'] ?? '' ) );
			$savings_ms  = (int) ( $opp['savings_ms'] ?? 0 );
			$savings_str = $savings_ms > 0 ? round( $savings_ms / 1000, 2 ) . 's potential saving' : 'no time saving estimate';
			$opp_lines[] = "- id: $id | title: $title | $savings_str" . ( $description ? " | detail: $description" : '' );
		}

		$lab_parts = array();
		$ms_keys   = array( 'lcp', 'inp', 'fcp', 'ttfb', 'tbt' );
		foreach ( $ms_keys as $k ) {
			if ( isset( $lab[ $k ] ) && is_numeric( $lab[ $k ] ) ) {
				$lab_parts[] = strtoupper( $k ) . '=' . round( $lab[ $k ] / 1000, 2 ) . 's';
			}
		}
		if ( isset( $lab['cls'] ) && is_numeric( $lab['cls'] ) ) {
			$lab_parts[] = 'CLS=' . round( (float) $lab['cls'], 3 );
		}
		$lab_summary = $lab_parts ? implode( ', ', $lab_parts ) : 'unavailable';

		$score_str = null !== $perf_score ? $perf_score : 'unknown';

		$instructions = 'You are a WordPress performance expert. '
			. 'Your job is to translate PageSpeed Insights opportunities into plain-English explanations and concrete WordPress-specific fixes. '
			. 'For each opportunity: '
			. '(1) explain in 1–2 sentences WHY it hurts performance and SEO in plain English (no jargon); '
			. '(2) provide ONE specific WordPress action — name a plugin, a theme setting, a WordPress admin option, or a hosting config change; '
			. '(3) assign a priority (critical/high/medium/low) based on impact; '
			. '(4) assign effort (quick = < 5 min, moderate = 5–30 min, involved = > 30 min or requires developer). '
			. 'Respond ONLY with a valid JSON array. No markdown, no code fences, no explanation outside the JSON. '
			. 'Each element must have exactly these keys: '
			. '"opportunity_id" (string — the id from input), '
			. '"title" (string — the opportunity title), '
			. '"plain_explanation" (string — max 150 chars), '
			. '"wordpress_fix" (string — max 200 chars, must be a specific actionable step), '
			. '"priority" (string — one of: critical, high, medium, low), '
			. '"effort" (string — one of: quick, moderate, involved).';

		$opp_block    = implode( "\n", $opp_lines );
		$user_content = "Analyse these PageSpeed Insights opportunities for the WordPress page: $url\n\n"
			. "Device strategy: $strategy | Lighthouse performance score: $score_str/100 | Lab metrics: $lab_summary\n\n"
			. "Opportunities:\n$opp_block\n\n"
			. 'Return a JSON array with one object per opportunity, preserving the order listed above.';

		$parsed = $this->parse_ai_json_reply( AIProvider::get_reply( $user_content, '', $instructions ) );

		if ( is_wp_error( $parsed ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $parsed->get_error_message(),
				),
				500
			);
		}

		$allowed_priority = array( 'critical', 'high', 'medium', 'low' );
		$allowed_effort   = array( 'quick', 'moderate', 'involved' );
		$suggestions      = array();

		foreach ( $parsed as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$opp_id = sanitize_key( (string) ( $item['opportunity_id'] ?? '' ) );
			$title  = sanitize_text_field( (string) ( $item['title'] ?? '' ) );
			if ( '' === $opp_id && '' === $title ) {
				continue;
			}

			$priority = sanitize_key( (string) ( $item['priority'] ?? 'medium' ) );
			if ( ! in_array( $priority, $allowed_priority, true ) ) {
				$priority = 'medium';
			}

			$effort = sanitize_key( (string) ( $item['effort'] ?? 'moderate' ) );
			if ( ! in_array( $effort, $allowed_effort, true ) ) {
				$effort = 'moderate';
			}

			$suggestions[] = array(
				'opportunity_id'    => $opp_id,
				'title'             => $title,
				'plain_explanation' => mb_substr( sanitize_text_field( (string) ( $item['plain_explanation'] ?? '' ) ), 0, 300 ),
				'wordpress_fix'     => mb_substr( sanitize_text_field( (string) ( $item['wordpress_fix'] ?? '' ) ), 0, 400 ),
				'priority'          => $priority,
				'effort'            => $effort,
			);
		}

		$result = array(
			'url'               => $url,
			'strategy'          => $strategy,
			'performance_score' => $perf_score,
			'suggestions'       => $suggestions,
			'generated_at'      => gmdate( 'c' ),
			'cached'            => false,
		);

		set_transient( $cache_key, $result, 12 * HOUR_IN_SECONDS );

		return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
	}

	/* ====================================================================
	 *  Google Search Console
	 * ==================================================================== */

	/**
	 * Run a GSC Search Analytics query.
	 *
	 * Body params:
	 *   - site_url: string (required).
	 *   - days: int (default 28).
	 *   - dimensions: string[] (default ['query']).
	 *   - page: string (optional URL filter).
	 *   - row_limit: int (default 50).
	 *   - force_refresh: bool.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function gsc_queries( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$site_url = isset( $payload['site_url'] ) ? sanitize_text_field( (string) $payload['site_url'] ) : '';
		$args     = array(
			'days'       => isset( $payload['days'] ) ? (int) $payload['days'] : 28,
			'dimensions' => isset( $payload['dimensions'] ) && is_array( $payload['dimensions'] )
				? array_map( 'sanitize_key', $payload['dimensions'] )
				: array( 'query' ),
			'page'       => isset( $payload['page'] ) ? esc_url_raw( (string) $payload['page'] ) : '',
			'row_limit'  => isset( $payload['row_limit'] ) ? (int) $payload['row_limit'] : 50,
		);
		$force    = ! empty( $payload['force_refresh'] );

		$result = ( new SearchConsoleClient() )->query( $site_url, $args, $force );
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Run a GSC URL Inspection.
	 *
	 * Body params:
	 *   - site_url: string (required).
	 *   - inspect_url: string (required).
	 *   - force_refresh: bool.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function gsc_inspect( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$site_url    = isset( $payload['site_url'] ) ? sanitize_text_field( (string) $payload['site_url'] ) : '';
		$inspect_url = isset( $payload['inspect_url'] ) ? esc_url_raw( (string) $payload['inspect_url'] ) : '';
		$force       = ! empty( $payload['force_refresh'] );

		$result = ( new SearchConsoleClient() )->inspect( $site_url, $inspect_url, $force );
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Analyse pre-fetched GSC query rows with AI and surface quick-win opportunities.
	 *
	 * Body params:
	 *   - rows:      array  (required) GscQueryRow objects already fetched on the client.
	 *   - site_url:  string (required) Used only for the cache key.
	 *   - days:      int    (default 28).
	 *   - force_refresh: bool.
	 *
	 * Returns a JSON object:
	 *   { wins: GscQuickWin[], total_analyzed: int, site_url: string, days: int,
	 *     generated_at: string, cached: bool }
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function gsc_quick_wins( $request ) {
		$payload  = json_decode( $request->get_body(), true );
		$payload  = is_array( $payload ) ? $payload : array();

		$site_url = isset( $payload['site_url'] ) ? sanitize_text_field( (string) $payload['site_url'] ) : '';
		$days     = isset( $payload['days'] ) ? absint( $payload['days'] ) : 28;
		$rows     = isset( $payload['rows'] ) && is_array( $payload['rows'] ) ? $payload['rows'] : array();
		$force    = ! empty( $payload['force_refresh'] );

		if ( empty( $rows ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'No GSC rows provided. Fetch Search Console data first.', 'antimanual' ),
				),
				400
			);
		}

		$cache_key = 'atml_gsc_qw_' . md5( $site_url . '_' . $days . '_' . json_encode( array_slice( $rows, 0, 50 ) ) );

		if ( ! $force ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached && is_array( $cached ) ) {
				$cached['cached'] = true;
				return rest_ensure_response( array( 'success' => true, 'data' => $cached ) );
			}
		}

		$candidates = array_filter(
			$rows,
			function ( $row ) {
				$pos  = isset( $row['position'] ) ? (float) $row['position'] : 0;
				$impr = isset( $row['impressions'] ) ? (int) $row['impressions'] : 0;
				return $pos >= 4.0 && $pos <= 10.0 && $impr >= 50;
			}
		);

		usort(
			$candidates,
			function ( $a, $b ) {
				return (int) ( $b['impressions'] ?? 0 ) - (int) ( $a['impressions'] ?? 0 );
			}
		);
		$candidates     = array_slice( $candidates, 0, 20 );
		$total_analyzed = count( $candidates );

		if ( 0 === $total_analyzed ) {
			$result = array(
				'wins'           => array(),
				'total_analyzed' => 0,
				'site_url'       => $site_url,
				'days'           => $days,
				'generated_at'   => gmdate( 'c' ),
				'cached'         => false,
			);
			set_transient( $cache_key, $result, 6 * HOUR_IN_SECONDS );
			return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
		}

		$lines = array( 'query | position | impressions | clicks | ctr' );
		foreach ( $candidates as $row ) {
			$query   = isset( $row['query'] ) ? sanitize_text_field( (string) $row['query'] ) : '';
			$pos     = round( (float) ( $row['position'] ?? 0 ), 1 );
			$impr    = (int) ( $row['impressions'] ?? 0 );
			$clks    = (int) ( $row['clicks'] ?? 0 );
			$ctr     = round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 );
			$lines[] = "$query | $pos | $impr | $clks | $ctr%";
		}
		$table = implode( "\n", $lines );

		$instructions = 'You are an expert SEO strategist. '
			. 'Analyse the provided Search Console query data and identify quick-win optimisation opportunities. '
			. 'A quick win is a query already ranking on page 1 (position 4–10) where a targeted improvement to '
			. 'the title tag, meta description, or content can increase clicks or ranking within days. '
			. 'For each query classify the opportunity and give ONE concrete, actionable instruction. '
			. 'Respond ONLY with a valid JSON array. No markdown, no code fences, no explanation outside the JSON. '
			. 'Each element must have exactly these keys: '
			. '"query" (string), "opportunity_type" (string — one of: "Low CTR", "Near Page 1", "Title Mismatch", '
			. '"Expand Content", "Add Schema", "Improve Meta"), '
			. '"action" (string — one specific sentence, max 120 chars), '
			. '"priority" (string — one of: "high", "medium", "low").';

		$user_content = "Here are the top Search Console queries ranking in positions 4–10 for {$site_url} over the last {$days} days:\n\n"
			. $table . "\n\n"
			. 'CTR values are expressed as percentages. '
			. 'Return a JSON array with one object per query. '
			. 'Sort the array from highest to lowest priority.';

		$parsed = $this->parse_ai_json_reply( AIProvider::get_reply( $user_content, '', $instructions ) );

		if ( is_wp_error( $parsed ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $parsed->get_error_message(),
				),
				500
			);
		}

		$gsc_map = array();
		foreach ( $candidates as $row ) {
			$q = strtolower( sanitize_text_field( (string) ( $row['query'] ?? '' ) ) );
			if ( '' !== $q ) {
				$gsc_map[ $q ] = $row;
			}
		}

		$allowed_types    = array( 'Low CTR', 'Near Page 1', 'Title Mismatch', 'Expand Content', 'Add Schema', 'Improve Meta' );
		$allowed_priority = array( 'high', 'medium', 'low' );
		$wins             = array();

		foreach ( $parsed as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$query = sanitize_text_field( (string) ( $item['query'] ?? '' ) );
			if ( '' === $query ) {
				continue;
			}

			$opp_type = sanitize_text_field( (string) ( $item['opportunity_type'] ?? 'Low CTR' ) );
			if ( ! in_array( $opp_type, $allowed_types, true ) ) {
				$opp_type = 'Low CTR';
			}

			$priority = sanitize_text_field( (string) ( $item['priority'] ?? 'medium' ) );
			if ( ! in_array( $priority, $allowed_priority, true ) ) {
				$priority = 'medium';
			}

			$action = sanitize_text_field( (string) ( $item['action'] ?? '' ) );
			$action = mb_substr( $action, 0, 200 );

			// Merge GSC metrics.
			$gsc_row = $gsc_map[ strtolower( $query ) ] ?? array();

			$wins[] = array(
				'query'            => $query,
				'position'         => round( (float) ( $gsc_row['position'] ?? 0 ), 1 ),
				'impressions'      => (int) ( $gsc_row['impressions'] ?? 0 ),
				'clicks'           => (int) ( $gsc_row['clicks'] ?? 0 ),
				'ctr'              => (float) ( $gsc_row['ctr'] ?? 0 ),
				'opportunity_type' => $opp_type,
				'action'           => $action,
				'priority'         => $priority,
			);
		}

		$result = array(
			'wins'           => $wins,
			'total_analyzed' => $total_analyzed,
			'site_url'       => $site_url,
			'days'           => $days,
			'generated_at'   => gmdate( 'c' ),
			'cached'         => false,
		);

		set_transient( $cache_key, $result, 6 * HOUR_IN_SECONDS );

		return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
	}

	/* ====================================================================
	 *  Google Analytics 4
	 * ==================================================================== */

	/**
	 * Run a GA4 organic-search top-pages report.
	 *
	 * Body params:
	 *   - property_id: string (required).
	 *   - days: int (default 28).
	 *   - row_limit: int (default 25).
	 *   - force_refresh: bool.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function ga4_organic( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$property_id = isset( $payload['property_id'] ) ? sanitize_text_field( (string) $payload['property_id'] ) : '';
		$args        = array(
			'days'      => isset( $payload['days'] ) ? (int) $payload['days'] : 28,
			'row_limit' => isset( $payload['row_limit'] ) ? (int) $payload['row_limit'] : 25,
		);
		$force       = ! empty( $payload['force_refresh'] );

		$result = ( new GA4Client() )->organic_top_pages( $property_id, $args, $force );
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * List all GA4 properties accessible to the configured service account.
	 *
	 * @return \WP_REST_Response
	 */
	public function ga4_properties() {
		$result = ( new GA4Client() )->list_properties();
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success'    => true,
				'data'       => array( 'properties' => $result ),
			)
		);
	}

	/* ====================================================================
	 *  Opportunity Ranker (cross-API)
	 * ==================================================================== */

	/**
	 * Cross-API Opportunity Ranker.
	 *
	 * Joins GA4 organic-traffic data with Search Console page metrics and an
	 * optional PageSpeed Insights score, then uses AI to rank pages from highest
	 * to lowest SEO growth opportunity and return one specific action per page.
	 *
	 * Body params:
	 *   - ga4_rows:      array  (required) Ga4OrganicRow objects already fetched.
	 *   - gsc_rows:      array  (optional) GscQueryRow objects in page dimension.
	 *   - cwv_url:       string (optional) URL of the PSI-analyzed page.
	 *   - cwv_score:     int    (optional) Lighthouse performance score 0–100.
	 *   - cwv_lab:       array  (optional) Lab metric values (lcp, inp, cls, …).
	 *   - site_url:      string Site URL used for context and cache key.
	 *   - days:          int    Date-range window (default 28).
	 *   - force_refresh: bool   Skip the transient cache.
	 *
	 * Returns:
	 *   { items: OpportunityRankerItem[], total, data_sources, site_url, days,
	 *     generated_at, cached }
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function opportunity_ranker( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$ga4_rows  = isset( $payload['ga4_rows'] ) && is_array( $payload['ga4_rows'] ) ? $payload['ga4_rows'] : array();
		$gsc_rows  = isset( $payload['gsc_rows'] ) && is_array( $payload['gsc_rows'] ) ? $payload['gsc_rows'] : array();
		$site_url  = isset( $payload['site_url'] ) ? sanitize_text_field( (string) $payload['site_url'] ) : '';
		$days      = isset( $payload['days'] ) ? absint( $payload['days'] ) : 28;
		$cwv_url   = isset( $payload['cwv_url'] ) ? esc_url_raw( (string) $payload['cwv_url'] ) : '';
		$cwv_score = isset( $payload['cwv_score'] ) && is_numeric( $payload['cwv_score'] )
			? (int) $payload['cwv_score']
			: null;
		$cwv_lab   = isset( $payload['cwv_lab'] ) && is_array( $payload['cwv_lab'] ) ? $payload['cwv_lab'] : array();
		$force     = ! empty( $payload['force_refresh'] );

		if ( empty( $ga4_rows ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'GA4 data is required. Fetch organic pages first.', 'antimanual' ),
				),
				400
			);
		}

		$cache_key = 'atml_opp_ranker_' . md5(
			$site_url . '_' . $days
			. '_' . wp_json_encode( array_slice( $ga4_rows, 0, 25 ) )
			. '_' . wp_json_encode( array_slice( $gsc_rows, 0, 25 ) )
			. '_' . $cwv_url . '_' . $cwv_score
		);

		if ( ! $force ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached && is_array( $cached ) ) {
				$cached['cached'] = true;
				return rest_ensure_response( array( 'success' => true, 'data' => $cached ) );
			}
		}

		// Build GSC page lookup indexed by path (strip domain for matching with GA4 paths).
		$gsc_map = array();
		foreach ( $gsc_rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$page = (string) ( $row['page'] ?? '' );
			if ( '' === $page ) {
				continue;
			}
			$parsed_path = wp_parse_url( $page, PHP_URL_PATH );
			if ( ! is_string( $parsed_path ) || '' === $parsed_path ) {
				continue;
			}
			$gsc_map[ rtrim( $parsed_path, '/' ) ] = $row;
		}

		// Determine the path of the CWV-analyzed URL for PSI overlay.
		$cwv_path = '';
		if ( '' !== $cwv_url ) {
			$p = wp_parse_url( $cwv_url, PHP_URL_PATH );
			if ( is_string( $p ) && '' !== $p ) {
				$cwv_path = rtrim( $p, '/' );
			}
		}

		// Enrich top GA4 pages with GSC and CWV data.
		$enriched     = array();
		$top_ga4_rows = array_slice( $ga4_rows, 0, 20 );

		foreach ( $top_ga4_rows as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$path  = rtrim( sanitize_text_field( (string) ( $row['page_path'] ?? '' ) ), '/' );
			$entry = array(
				'page_path'        => $path,
				'page_title'       => mb_substr( sanitize_text_field( (string) ( $row['page_title'] ?? '' ) ), 0, 80 ),
				'sessions'         => (int) ( $row['sessions'] ?? 0 ),
				'engagement_rate'  => round( (float) ( $row['engagement_rate'] ?? 0 ), 4 ),
				'avg_duration_sec' => round( (float) ( $row['avg_session_duration_sec'] ?? 0 ), 1 ),
			);

			if ( isset( $gsc_map[ $path ] ) ) {
				$gsc = $gsc_map[ $path ];
				$entry['position']    = round( (float) ( $gsc['position'] ?? 0 ), 1 );
				$entry['clicks']      = (int) ( $gsc['clicks'] ?? 0 );
				$entry['impressions'] = (int) ( $gsc['impressions'] ?? 0 );
				$entry['ctr']         = round( (float) ( $gsc['ctr'] ?? 0 ), 4 );
			}

			if ( '' !== $cwv_path && $path === $cwv_path && null !== $cwv_score ) {
				$entry['performance_score'] = $cwv_score;
				foreach ( array( 'lcp', 'inp', 'cls', 'fcp', 'ttfb', 'tbt' ) as $k ) {
					if ( isset( $cwv_lab[ $k ] ) && is_numeric( $cwv_lab[ $k ] ) ) {
						$entry[ 'cwv_' . $k ] = (float) $cwv_lab[ $k ];
					}
				}
			}

			$enriched[] = $entry;
		}

		// Determine which data sources are available for the AI prompt context.
		$data_sources = array( 'ga4' );
		if ( ! empty( $gsc_map ) ) {
			$data_sources[] = 'gsc';
		}
		if ( '' !== $cwv_path && null !== $cwv_score ) {
			$data_sources[] = 'cwv';
		}
		$sources_str = implode( ', ', $data_sources );

		// Build a compact pipe-delimited table for the AI prompt.
		$header = 'page_path | title | sessions | engagement | position | clicks | impressions | ctr | psi_score';
		$lines  = array( $header );
		foreach ( $enriched as $row ) {
			$lines[] = implode(
				' | ',
				array(
					$row['page_path'],
					$row['page_title'],
					$row['sessions'],
					round( $row['engagement_rate'] * 100, 1 ) . '%',
					isset( $row['position'] )          ? $row['position']                    : 'n/a',
					isset( $row['clicks'] )            ? $row['clicks']                      : 'n/a',
					isset( $row['impressions'] )       ? $row['impressions']                 : 'n/a',
					isset( $row['ctr'] )               ? round( $row['ctr'] * 100, 2 ) . '%' : 'n/a',
					isset( $row['performance_score'] ) ? $row['performance_score']           : 'n/a',
				)
			);
		}
		$table = implode( "\n", $lines );

		$instructions = 'You are a senior SEO strategist. '
			. 'You have been given a table of a website\'s top organic landing pages enriched with data from multiple Google APIs: '
			. 'GA4 (sessions, engagement rate), Search Console (position, clicks, impressions, CTR), '
			. 'and optionally PageSpeed Insights (PSI score 0–100). '
			. 'Your task: rank these pages from HIGHEST to LOWEST opportunity for traffic and revenue growth, '
			. 'then for each page give ONE specific, actionable instruction. '
			. 'Use these signals to judge opportunity: '
			. 'good position (4–10) but low CTR = title/meta fix; '
			. 'high impressions but few clicks = CTR optimization; '
			. 'high sessions but low engagement = content or UX issue; '
			. 'poor PSI score (< 50) = performance blocking ranking; '
			. 'high sessions + poor position = content depth gap. '
			. 'Columns marked "n/a" were unavailable — ignore them for that page. '
			. 'Respond ONLY with a valid JSON array. No markdown, no code fences, no text outside the JSON. '
			. 'Each element must have exactly these keys: '
			. '"page_path" (string — the exact path from the input table), '
			. '"priority" (string — one of: "critical", "high", "medium", "low"), '
			. '"opportunity_type" (string — one short label, e.g. "Ranking Gap", "Low CTR", '
			. '"Performance Issue", "Engagement Drop", "Content Expansion", "CTR Boost", "Schema Opportunity"), '
			. '"reason" (string — 1–2 sentences max 200 chars explaining WHY this page ranks here), '
			. '"action" (string — ONE concrete instruction max 150 chars, e.g. a plugin name, specific tag to change, or content action).';

		$user_content = "Rank these top organic pages for the site {$site_url} over the last {$days} days by SEO growth opportunity. "
			. "Available data sources: {$sources_str}.\n\n"
			. $table . "\n\n"
			. 'Position, CTR, clicks, and impressions are "n/a" when GSC page-dimension data was not provided. '
			. 'PSI score is "n/a" when PageSpeed Insights data was not provided for that specific page. '
			. 'Return a JSON array, one object per page, sorted from highest to lowest opportunity.';

		$parsed = $this->parse_ai_json_reply( AIProvider::get_reply( $user_content, '', $instructions ) );

		if ( is_wp_error( $parsed ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $parsed->get_error_message(),
				),
				500
			);
		}

		// Build a lookup map for enriched data so we can merge it back into the AI output.
		$enriched_map = array();
		foreach ( $enriched as $row ) {
			$enriched_map[ $row['page_path'] ] = $row;
		}

		$allowed_priority = array( 'critical', 'high', 'medium', 'low' );
		$items            = array();

		foreach ( $parsed as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}

			$path = sanitize_text_field( (string) ( $item['page_path'] ?? '' ) );
			if ( '' === $path ) {
				continue;
			}

			$priority = sanitize_key( (string) ( $item['priority'] ?? 'medium' ) );
			if ( ! in_array( $priority, $allowed_priority, true ) ) {
				$priority = 'medium';
			}

			$opp_type = sanitize_text_field( (string) ( $item['opportunity_type'] ?? 'Ranking Gap' ) );
			$reason   = mb_substr( sanitize_text_field( (string) ( $item['reason'] ?? '' ) ), 0, 300 );
			$action   = mb_substr( sanitize_text_field( (string) ( $item['action'] ?? '' ) ), 0, 200 );

			// Merge numeric metrics from the enriched map back in.
			$base = $enriched_map[ $path ] ?? array();

			$out = array_merge(
				$base,
				array(
					'page_path'        => $path,
					'priority'         => $priority,
					'opportunity_type' => $opp_type,
					'reason'           => $reason,
					'action'           => $action,
				)
			);

			// Strip internal CWV sub-keys not needed by the client.
			foreach ( array( 'cwv_lcp', 'cwv_inp', 'cwv_cls', 'cwv_fcp', 'cwv_ttfb', 'cwv_tbt' ) as $k ) {
				unset( $out[ $k ] );
			}

			$items[] = $out;
		}

		$result = array(
			'items'        => $items,
			'total'        => count( $items ),
			'data_sources' => $data_sources,
			'site_url'     => $site_url,
			'days'         => $days,
			'generated_at' => gmdate( 'c' ),
			'cached'       => false,
		);

		set_transient( $cache_key, $result, 6 * HOUR_IN_SECONDS );

		return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
	}

	/* ====================================================================
	 *  Google Indexing API
	 * ==================================================================== */

	/**
	 * Smart Indexing Gate — AI-evaluated indexing readiness for WordPress posts.
	 *
	 * Reads the last N published posts (or a specific list of post IDs), gathers
	 * SEO signals (title, meta description, word count, noindex flag, recent
	 * submission history), then asks the AI to assign a gate status:
	 *   - "submit"  Page is ready — add it to the indexing queue.
	 *   - "hold"    Page has issues that must be fixed first.
	 *   - "skip"    Recently submitted (< 7 days) or no value in re-submitting.
	 *
	 * Saves Indexing API quota by filtering out pages that are not ready or
	 * were already submitted recently.
	 *
	 * Body params:
	 *   - post_ids      (optional): int[]  Specific post IDs to evaluate.
	 *   - limit         (optional): int    Auto-scan limit (default 20, max 50).
	 *   - force_refresh (optional): bool   Skip the transient cache.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response|\WP_Error
	 */
	public function indexing_gate( $request ) {
		$payload  = json_decode( $request->get_body(), true );
		$payload  = is_array( $payload ) ? $payload : array();

		$raw_ids = isset( $payload['post_ids'] ) && is_array( $payload['post_ids'] ) ? $payload['post_ids'] : array();
		$limit   = isset( $payload['limit'] ) ? max( 1, min( 50, absint( $payload['limit'] ) ) ) : 20;
		$force   = ! empty( $payload['force_refresh'] );

		$post_ids = array_values( array_filter( array_map( 'absint', $raw_ids ) ) );

		if ( empty( $post_ids ) ) {
			$fetched  = get_posts(
				array(
					'numberposts'            => $limit,
					'post_status'            => 'publish',
					'post_type'              => 'post',
					'orderby'                => 'modified',
					'order'                  => 'DESC',
					'no_found_rows'          => true,
					'fields'                 => 'ids',
					'update_post_meta_cache' => false,
					'update_post_term_cache' => false,
				)
			);
			$post_ids = array_map( 'intval', (array) $fetched );
		}

		if ( empty( $post_ids ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'No published posts found to evaluate.', 'antimanual' ),
				),
				400
			);
		}

		$cache_key = 'atml_idx_gate_' . md5( implode( ',', $post_ids ) );
		if ( ! $force ) {
			$cached = get_transient( $cache_key );
			if ( false !== $cached && is_array( $cached ) ) {
				$cached['cached'] = true;
				return rest_ensure_response( array( 'success' => true, 'data' => $cached ) );
			}
		}

		// Build a set of recently submitted URLs (last 7 days, successful only).
		$log            = ( new IndexingApiClient() )->get_log( 100 );
		$seven_days_ago = time() - ( 7 * DAY_IN_SECONDS );
		$recent_urls    = array();
		foreach ( $log as $entry ) {
			if (
				! empty( $entry['success'] )
				&& ! empty( $entry['url'] )
				&& ! empty( $entry['submitted_at'] )
			) {
				$ts = strtotime( (string) $entry['submitted_at'] );
				if ( $ts && $ts >= $seven_days_ago ) {
					$recent_urls[ esc_url_raw( (string) $entry['url'] ) ] = true;
				}
			}
		}

		// Gather per-post signals.
		$pages = array();
		foreach ( $post_ids as $post_id ) {
			$post = get_post( $post_id );
			if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status ) {
				continue;
			}

			$url        = (string) get_permalink( $post_id );
			$word_count = (int) str_word_count( wp_strip_all_tags( $post->post_content ) );
			$modified   = strtotime( $post->post_modified_gmt );
			$days_since = $modified ? (int) floor( ( time() - $modified ) / DAY_IN_SECONDS ) : 999;

			// SEO plugin: title meta.
			$has_seo_title = false;
			foreach ( array( 'rank_math_title', '_yoast_wpseo_title', '_aioseo_title', '_genesis_title' ) as $k ) {
				$v = get_post_meta( $post_id, $k, true );
				if ( is_string( $v ) && '' !== trim( $v ) ) {
					$has_seo_title = true;
					break;
				}
			}

			// SEO plugin: description meta.
			$has_seo_desc = false;
			foreach ( array( 'rank_math_description', '_yoast_wpseo_metadesc', '_aioseo_description', '_genesis_description' ) as $k ) {
				$v = get_post_meta( $post_id, $k, true );
				if ( is_string( $v ) && '' !== trim( $v ) ) {
					$has_seo_desc = true;
					break;
				}
			}

			// noindex detection (Rank Math, Yoast, The SEO Framework).
			$is_noindex = false;
			$rm_robots  = get_post_meta( $post_id, 'rank_math_robots', true );
			if ( is_array( $rm_robots ) && in_array( 'noindex', $rm_robots, true ) ) {
				$is_noindex = true;
			}
			if ( ! $is_noindex && '1' === (string) get_post_meta( $post_id, '_yoast_wpseo_meta-robots-noindex', true ) ) {
				$is_noindex = true;
			}
			if ( ! $is_noindex && '1' === (string) get_post_meta( $post_id, '_genesis_noindex', true ) ) {
				$is_noindex = true;
			}

			$pages[] = array(
				'post_id'             => $post_id,
				'url'                 => esc_url_raw( $url ),
				'post_title'          => mb_substr( sanitize_text_field( $post->post_title ), 0, 80 ),
				'word_count'          => $word_count,
				'has_seo_title'       => $has_seo_title,
				'has_seo_desc'        => $has_seo_desc,
				'is_noindex'          => $is_noindex,
				'recently_submitted'  => isset( $recent_urls[ esc_url_raw( $url ) ] ),
				'days_since_modified' => $days_since,
			);
		}

		if ( empty( $pages ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => __( 'No valid published posts found to evaluate.', 'antimanual' ),
				),
				400
			);
		}

		// Pipe-delimited table for the AI prompt.
		$header = 'url | title | words | has_seo_title | has_seo_desc | is_noindex | recently_submitted | days_since_modified';
		$lines  = array( $header );
		foreach ( $pages as $p ) {
			$lines[] = implode(
				' | ',
				array(
					$p['url'],
					$p['post_title'],
					$p['word_count'],
					$p['has_seo_title']       ? 'yes' : 'no',
					$p['has_seo_desc']        ? 'yes' : 'no',
					$p['is_noindex']          ? 'yes' : 'no',
					$p['recently_submitted']  ? 'yes' : 'no',
					$p['days_since_modified'] . 'd',
				)
			);
		}

		$instructions = <<<'PROMPT'
You are an SEO indexing readiness evaluator for WordPress sites.

Analyze the provided pages and decide which should be submitted to Google's Indexing API.

Rules:
- If is_noindex = yes: gate_status MUST be "hold".
- If recently_submitted = yes: gate_status MUST be "skip".
- If words < 300: gate_status should be "hold" (thin content).
- If has_seo_title = no AND has_seo_desc = no: gate_status should be "hold".
- Otherwise evaluate holistically: a page with 300+ words, a title, and a meta description is a candidate for "submit".

Priority rules:
- "high": days_since_modified <= 7 and gate_status = "submit" (fresh content ready to index).
- "medium": gate_status = "submit" but older content.
- "low": gate_status = "hold" or "skip".

Return ONLY a valid JSON array. Each element must have exactly these keys:
- url: string (exact URL from the table — do not alter it)
- gate_status: "submit" | "hold" | "skip"
- readiness_score: integer 0–100 (100 = perfectly ready, 0 = noindex/broken)
- top_issue: string (the single most important issue, e.g. "Thin content", "Missing meta description", "Recently submitted", "None")
- reason: string (1–2 sentences explaining the decision)
- priority: "high" | "medium" | "low"

No markdown, no code fences, no explanation — only the JSON array.
PROMPT;

		$user_content = "Evaluate these WordPress pages for Google Indexing API submission readiness:\n\n" . implode( "\n", $lines );

		$parsed = $this->parse_ai_json_reply( AIProvider::get_reply( $user_content, '', $instructions ) );
		if ( is_wp_error( $parsed ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $parsed->get_error_message(),
				),
				500
			);
		}

		// Index AI output by URL for O(1) merging.
		$valid_statuses = array( 'submit', 'hold', 'skip' );
		$ai_map         = array();
		foreach ( $parsed as $ai_item ) {
			if ( ! is_array( $ai_item ) || empty( $ai_item['url'] ) ) {
				continue;
			}
			$ai_url            = esc_url_raw( (string) $ai_item['url'] );
			$ai_map[ $ai_url ] = array(
				'gate_status'     => in_array( $ai_item['gate_status'] ?? '', $valid_statuses, true )
					? $ai_item['gate_status']
					: 'hold',
				'readiness_score' => max( 0, min( 100, (int) ( $ai_item['readiness_score'] ?? 50 ) ) ),
				'top_issue'       => mb_substr( sanitize_text_field( (string) ( $ai_item['top_issue'] ?? '' ) ), 0, 120 ),
				'reason'          => mb_substr( sanitize_text_field( (string) ( $ai_item['reason'] ?? '' ) ), 0, 300 ),
				'priority'        => in_array( $ai_item['priority'] ?? '', array( 'high', 'medium', 'low' ), true )
					? $ai_item['priority']
					: 'low',
			);
		}

		$items        = array();
		$submit_count = 0;
		$hold_count   = 0;
		$skip_count   = 0;

		foreach ( $pages as $p ) {
			$ai = $ai_map[ $p['url'] ] ?? array(
				'gate_status'     => 'hold',
				'readiness_score' => 0,
				'top_issue'       => __( 'AI output unavailable', 'antimanual' ),
				'reason'          => __( 'Could not map AI output to this URL.', 'antimanual' ),
				'priority'        => 'low',
			);

			if ( 'submit' === $ai['gate_status'] ) {
				++$submit_count;
			} elseif ( 'hold' === $ai['gate_status'] ) {
				++$hold_count;
			} else {
				++$skip_count;
			}

			$items[] = array_merge( $p, $ai );
		}

		$result = array(
			'items'        => $items,
			'total'        => count( $items ),
			'submit_count' => $submit_count,
			'hold_count'   => $hold_count,
			'skip_count'   => $skip_count,
			'quota_saved'  => $hold_count + $skip_count,
			'generated_at' => gmdate( 'c' ),
			'cached'       => false,
		);

		set_transient( $cache_key, $result, 4 * HOUR_IN_SECONDS );

		return rest_ensure_response( array( 'success' => true, 'data' => $result ) );
	}

	/**
	 * Submit a URL to the Google Indexing API.
	 *
	 * Body params:
	 *   - url: string (required).
	 *   - type: 'URL_UPDATED' | 'URL_DELETED' (default URL_UPDATED).
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function indexing_submit( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$url  = isset( $payload['url'] ) ? esc_url_raw( (string) $payload['url'] ) : '';
		$type = isset( $payload['type'] ) ? sanitize_text_field( (string) $payload['type'] ) : 'URL_UPDATED';

		$result = ( new IndexingApiClient() )->submit( $url, $type );
		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Get Indexing API status for a URL.
	 *
	 * Body params:
	 *   - url: string (required).
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function indexing_status( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();

		$url    = isset( $payload['url'] ) ? esc_url_raw( (string) $payload['url'] ) : '';
		$result = ( new IndexingApiClient() )->get_status( $url );

		if ( is_wp_error( $result ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				),
				400
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Read the Indexing API submission log.
	 *
	 * Query params:
	 *   - limit: int (default 25).
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function indexing_log( $request ) {
		$limit = (int) ( $request->get_param( 'limit' ) ?? 25 );
		$log   = ( new IndexingApiClient() )->get_log( $limit );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array( 'entries' => $log ),
			)
		);
	}

	/**
	 * Toggle auto-submission on publish.
	 *
	 * Body params:
	 *   - enabled: bool.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function indexing_set_auto_submit( $request ) {
		$payload = json_decode( $request->get_body(), true );
		$payload = is_array( $payload ) ? $payload : array();
		$enabled = ! empty( $payload['enabled'] );

		( new IndexingApiClient() )->set_auto_submit_enabled( $enabled );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array( 'enabled' => $enabled ),
			)
		);
	}

	/**
	 * On post status transitions, schedule an Indexing API ping when appropriate.
	 *
	 * Triggers:
	 *   - Newly published or republished posts → URL_UPDATED.
	 *   - Posts moving from publish to trash/draft → URL_DELETED.
	 *
	 * Skips revisions, autosaves, password-protected posts, and post types
	 * whose visibility is "publicly_queryable=false".
	 *
	 * @param string   $new_status
	 * @param string   $old_status
	 * @param \WP_Post $post
	 */
	public function maybe_submit_to_indexing_api( $new_status, $old_status, $post ): void {
		if ( ! $post instanceof \WP_Post ) {
			return;
		}
		if ( wp_is_post_revision( $post->ID ) || wp_is_post_autosave( $post->ID ) ) {
			return;
		}
		if ( ! ( new IndexingApiClient() )->is_auto_submit_enabled() ) {
			return;
		}
		if ( ! GoogleAuthService::instance()->has_service_account() ) {
			return;
		}
		if ( ! empty( $post->post_password ) ) {
			return;
		}

		$post_type_object = get_post_type_object( $post->post_type );
		if ( ! $post_type_object || empty( $post_type_object->public ) ) {
			return;
		}

		$type = null;
		if ( 'publish' === $new_status ) {
			$type = 'URL_UPDATED';
		} elseif ( 'publish' === $old_status && in_array( $new_status, array( 'trash', 'draft', 'pending', 'private' ), true ) ) {
			$type = 'URL_DELETED';
		}

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

		$url = get_permalink( $post );
		if ( ! is_string( $url ) || '' === $url ) {
			return;
		}

		// Schedule asynchronously so the publish flow is not blocked by an HTTP round-trip.
		if ( ! wp_next_scheduled( 'atml_indexing_api_submit', array( $url, $type ) ) ) {
			wp_schedule_single_event( time(), 'atml_indexing_api_submit', array( $url, $type ) );
		}
	}

	/**
	 * Background handler that performs the actual Indexing API submission.
	 *
	 * @param string $url
	 * @param string $type
	 */
	public function run_indexing_api_submission( $url, $type = 'URL_UPDATED' ): void {
		$url  = is_string( $url ) ? $url : '';
		$type = is_string( $type ) ? $type : 'URL_UPDATED';
		if ( '' === $url ) {
			return;
		}
		( new IndexingApiClient() )->submit( $url, $type );
	}

	/* ====================================================================
	 *  Helpers
	 * ==================================================================== */

	/**
	 * Parse a JSON reply from the AI provider, stripping markdown fences.
	 *
	 * @param mixed $reply_data Return value of AIProvider::get_reply().
	 * @return array|\WP_Error Decoded array or WP_Error on failure.
	 */
	private function parse_ai_json_reply( $reply_data ) {
		if ( isset( $reply_data['error'] ) ) {
			return new \WP_Error( 'ai_error', (string) $reply_data['error'] );
		}

		$reply = is_array( $reply_data ) && isset( $reply_data['reply'] )
			? $reply_data['reply']
			: (string) $reply_data;

		$reply = preg_replace( '/^```(?:json)?\s*/i', '', trim( $reply ) );
		$reply = preg_replace( '/\s*```$/', '', (string) $reply );

		$parsed = json_decode( trim( (string) $reply ), true );

		if ( ! is_array( $parsed ) ) {
			return new \WP_Error(
				'ai_parse_failed',
				__( 'AI returned an unexpected format. Please try again.', 'antimanual' )
			);
		}

		return $parsed;
	}
}
