<?php
/**
 * Batch processing class for handling large URL lists
 *
 * @package ContentPerformanceAnalyzer
 * @since 1.0.0
 */

// Prevent direct access
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}


class CPAP_Batch_Processor {

	private $batch_size         = 10;
	private $max_execution_time = 25; // seconds per batch
	private $table_name;

	public function __construct() {
		global $wpdb;
		$this->table_name = $wpdb->prefix . 'cpanalyzer_urls';
		$this->batch_size = get_option( 'cpanalyzer_batch_size', 10 );
	}

	// NOTE: All AI provider/model resolution lives in CPAP_AI_Processor —
	// the single source of truth. Do not duplicate the mapping here.

	public function init() {
		add_action( 'cpanalyzer_batch_process', array( $this, 'process_batch' ) );
		add_action( 'cpanalyzer_off_peak_scan', array( $this, 'trigger_full_scan' ) );
		add_filter( 'cron_schedules', array( $this, 'add_batch_interval' ) );
		add_action( 'wp_ajax_cpanalyzer_start_batch_process', array( $this, 'ajax_start_batch_process' ) );
		add_action( 'wp_ajax_cpanalyzer_get_batch_status', array( $this, 'ajax_get_batch_status' ) );
		add_action( 'wp_ajax_cpanalyzer_retry_failed_url', array( $this, 'ajax_retry_failed_url' ) );
		add_action( 'wp_ajax_cpanalyzer_cancel_batch', array( $this, 'ajax_cancel_batch' ) );
	}

	public function add_batch_interval( $schedules ) {
		$schedules['every_minute'] = array(
			'interval' => 60,
			'display'  => __( 'Every Minute', 'content-performance-analyzer' ),
		);
		return $schedules;
	}

	public function process_batch() {
		global $wpdb;
		$this->log_event( 'batch_start', 'Starting batch processing.' );

		// Check if batch is really running or just stuck
		$batch_running    = get_transient( 'cpanalyzer_batch_running' );
		$batch_start_time = get_transient( 'cpanalyzer_batch_start_time' );

		if ( $batch_running && $batch_start_time ) {
			// If process has been running for more than 5 minutes, consider it stuck
			if ( ( time() - $batch_start_time ) > 300 ) {
				$this->log_event( 'batch_stuck', 'Clearing stuck batch process after 5 minutes.' );
				delete_transient( 'cpanalyzer_batch_running' );
				delete_transient( 'cpanalyzer_batch_start_time' );
			} else {
				$this->log_event( 'batch_skip', 'Batch already running. Skipping this trigger.' );
				return;
			}
		}

		set_transient( 'cpanalyzer_batch_running', true, 120 );
		set_transient( 'cpanalyzer_batch_start_time', time(), 120 );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table queries for batch processing
		$pending_urls = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is class property, safe
				"SELECT * FROM {$this->table_name} WHERE status = 'pending' ORDER BY priority ASC, id ASC LIMIT %d",
				$this->batch_size
			)
		);

		if ( empty( $pending_urls ) ) {
			$this->log_event( 'batch_empty', 'No more URLs to process. Clearing cron hook.' );
			wp_clear_scheduled_hook( 'cpanalyzer_batch_process' );
			delete_transient( 'cpanalyzer_batch_running' );
			delete_transient( 'cpanalyzer_batch_start_time' );
			update_option( 'cpanalyzer_batch_status', 'completed' );

			// Sync batch data to insights display
			$this->sync_to_insights();
			return;
		}

		$data_collector  = new CPAP_Data_Collector();
		$ai_processor    = new CPAP_AI_Processor();

		// Initialize PSI collector if enabled - FORCED LOGGING
		$psi_collector = null;
		$psi_enabled   = get_option( 'cpanalyzer_pagespeed_enable', 'no' );
		$psi_api_key   = get_option( 'cpanalyzer_pagespeed_api_key', '' );

		$this->debug_log( "[CPANALYZER BATCH] PSI initialization - enabled: $psi_enabled, has_key: " . ( ! empty( $psi_api_key ) ? 'yes' : 'no' ) );

		if ( $psi_enabled === 'yes' || $psi_enabled === true ) {
			$psi_collector = new CPAP_PageSpeed_Collector();
			if ( ! $psi_collector->is_enabled() ) {
				$this->debug_log( '[CPANALYZER BATCH] PSI enabled but not properly configured - skipping PSI analysis' );
				$psi_collector = null;
			} else {
				$this->debug_log( '[CPANALYZER BATCH] PSI collector initialized and ready' );
			}
		} else {
			$this->debug_log( "[CPANALYZER BATCH] PSI is disabled - enabled setting: $psi_enabled" );
		}

		// Collect bulk GA4 and GSC data for all pending URLs first (like working version)
		$all_ga_data  = array();
		$all_gsc_data = array();

		try {
			$all_ga_data  = $this->collect_bulk_ga4_data();
			$all_gsc_data = $this->collect_bulk_gsc_data();
			$this->log_event(
				'bulk_data_collected',
				'Successfully collected bulk GA4/GSC data',
				array(
					'ga_urls'  => count( $all_ga_data['current'] ),
					'gsc_urls' => count( $all_gsc_data ),
				)
			);
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_data_error', 'Error collecting bulk data', array( 'error' => $e->getMessage() ) );
		}

		foreach ( $pending_urls as $url_record ) {
			$start_time = microtime( true );
			$this->log_event(
				'url_processing_start',
				'Processing URL',
				array(
					'id'  => $url_record->id,
					'url' => $url_record->url,
				)
			);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table status updates for batch processing
			$wpdb->update( $this->table_name, array( 'status' => 'in_progress' ), array( 'id' => $url_record->id ) );

			try {
				$url             = $url_record->url;
				$normalized_path = cpanalyzer_normalize_path( $url );
				$result_data     = array();

				// Get data from bulk collection
				$ga_data                = $all_ga_data['current'][ $normalized_path ] ?? array();
				$result_data['ga_data'] = wp_json_encode( $ga_data );

				$gsc_data = $this->enrich_url_intelligence( $url, $ga_data, $all_gsc_data[ $normalized_path ] ?? array() );

				$content_data = $this->scrape_content_with_psi( $url, $ga_data, $gsc_data, $psi_collector, '[CPANALYZER BATCH]' );

				// The live scrape can override the diagnosis (noindex,
				// canonical mismatch) — encode gsc_data only after it.
				$gsc_data                = $this->apply_post_scrape_signals( $gsc_data, $content_data );
				$result_data['gsc_data'] = wp_json_encode( $gsc_data );

				$result_data['content_data'] = wp_json_encode( $content_data );

				$ai_payload = $this->prepare_ai_payload( $url, $ga_data, $gsc_data, $content_data );

				if ( ! empty( $ai_payload ) ) {
					$ai_suggestions                = $this->get_ai_suggestions_chunked( $ai_payload, $content_data );
					$result_data['ai_suggestions'] = wp_json_encode( $ai_suggestions );
				}

				$result_data['status']       = 'success';
				$result_data['last_scanned'] = current_time( 'mysql' );
				$result_data['scan_result']  = wp_json_encode(
					array(
						'summary'   => $this->generate_summary( $ga_data, $gsc_data, $content_data ),
						'timestamp' => time(),
					)
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Custom table updates for batch processing results
				$wpdb->update( $this->table_name, $result_data, array( 'id' => $url_record->id ) );
				$this->log_event(
					'url_processed',
					'Processed URL successfully',
					array(
						'id'              => $url_record->id,
						'ga_views'        => $ga_data['views'] ?? 0,
						'gsc_impressions' => $gsc_data['impressions'] ?? 0,
					)
				);

			} catch ( Exception $e ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Error status updates for processing failures
				$wpdb->update(
					$this->table_name,
					array(
						'status'        => 'failed',
						'error_message' => $e->getMessage(),
						'last_scanned'  => current_time( 'mysql' ),
					),
					array( 'id' => $url_record->id )
				);
				$this->log_event(
					'url_error',
					'Error processing URL',
					array(
						'id'    => $url_record->id,
						'error' => $e->getMessage(),
					)
				);
			}

			if ( ( microtime( true ) - $start_time ) > $this->max_execution_time ) {
				$this->log_event( 'batch_timeout', 'Batch processing time exceeded the limit, breaking loop.' );
				break;
			}
		}

		delete_transient( 'cpanalyzer_batch_running' );
		delete_transient( 'cpanalyzer_batch_start_time' );
	

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table queries for batch processing
		$remaining = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'pending' ) );
		if ( $remaining > 0 && ! wp_next_scheduled( 'cpanalyzer_batch_process' ) ) {
			wp_schedule_single_event( time() + 30, 'cpanalyzer_batch_process' );
			$this->log_event( 'batch_next', 'Scheduled next batch event.', array( 'remaining' => $remaining ) );
		} else {
			$this->log_event( 'batch_complete', 'Batch completed or no more pending URLs.' );
		}
	}

	/**
	 * Collect bulk GA4 data via OAuth Bearer token — no SDK.
	 *
	 * @return array{ current: array, previous: array }
	 */
	private function collect_bulk_ga4_data() {
		$oauth       = new CPAP_Google_OAuth();
		$token       = $oauth->get_valid_access_token();
		$property_id = get_option( 'cpanalyzer_property_id', '' );

		$ga_data = array( 'current' => array(), 'previous' => array() );

		if ( empty( $token ) || empty( $property_id ) ) {
			$this->log_event( 'bulk_ga4', 'OAuth token or GA4 property ID missing.' );
			return $ga_data;
		}

		$endpoint = 'https://analyticsdata.googleapis.com/v1beta/properties/' . rawurlencode( $property_id ) . ':runReport';
		$headers  = array(
			'Authorization' => 'Bearer ' . $token,
			'Content-Type'  => 'application/json',
		);

		// ---- Current period ------------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'dateRanges' => array( array( 'startDate' => '30daysAgo', 'endDate' => 'today' ) ),
				'dimensions' => array( array( 'name' => 'pagePath' ) ),
				'metrics'    => array(
					array( 'name' => 'screenPageViews' ),
					array( 'name' => 'activeUsers' ),
					array( 'name' => 'engagementRate' ),
					array( 'name' => 'averageSessionDuration' ),
				),
				// Explicit ordering: without it the API's row order is not
				// guaranteed, and on sites with more than 2500 paths the
				// homepage could fall outside the window entirely.
				'orderBys' => array( array( 'desc' => true, 'metric' => array( 'metricName' => 'screenPageViews' ) ) ),
				'limit' => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GA4 current' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page                        = cpanalyzer_normalize_path( $row['dimensionValues'][0]['value'] ?? '' );
				$m                           = $row['metricValues'] ?? array();
				$ga_data['current'][ $page ] = array(
					'views'      => (int) ( $m[0]['value'] ?? 0 ),
					'uniques'    => (int) ( $m[1]['value'] ?? 0 ),
					'engagement' => (float) ( $m[2]['value'] ?? 0 ),
					'duration'   => (float) ( $m[3]['value'] ?? 0 ),
					'form_fills' => 0,
					'views_prev' => 0,
				);
			}
			$this->log_event( 'bulk_ga4_current', 'GA4 current period collected', array( 'pages' => count( $ga_data['current'] ) ) );
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_ga4_error', 'GA4 current period error', array( 'error' => $e->getMessage() ) );
		}

		// ---- Previous period -----------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'dateRanges' => array( array( 'startDate' => '60daysAgo', 'endDate' => '31daysAgo' ) ),
				'dimensions' => array( array( 'name' => 'pagePath' ) ),
				'metrics'    => array(
					array( 'name' => 'screenPageViews' ),
					array( 'name' => 'engagementRate' ),
				),
				'orderBys'   => array( array( 'desc' => true, 'metric' => array( 'metricName' => 'screenPageViews' ) ) ),
				'limit'      => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GA4 previous' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page       = cpanalyzer_normalize_path( $row['dimensionValues'][0]['value'] ?? '' );
				$views      = (int) ( $row['metricValues'][0]['value'] ?? 0 );
				$engagement = (float) ( $row['metricValues'][1]['value'] ?? 0 );
				$ga_data['previous'][ $page ] = array( 'views' => $views );
				if ( isset( $ga_data['current'][ $page ] ) ) {
					$ga_data['current'][ $page ]['views_prev']      = $views;
					$ga_data['current'][ $page ]['engagement_prev'] = $engagement;
				}
			}
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_ga4_prev_error', 'GA4 previous period error', array( 'error' => $e->getMessage() ) );
		}

		// ---- Form submissions -----------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'dateRanges'      => array( array( 'startDate' => '30daysAgo', 'endDate' => 'today' ) ),
				'dimensions'      => array( array( 'name' => 'pagePath' ) ),
				'metrics'         => array( array( 'name' => 'eventCount' ) ),
				'dimensionFilter' => array(
					'filter' => array(
						'fieldName'    => 'eventName',
						'stringFilter' => array( 'value' => 'form_submission', 'matchType' => 'EXACT' ),
					),
				),
				'limit' => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GA4 forms' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page  = cpanalyzer_normalize_path( $row['dimensionValues'][0]['value'] ?? '' );
				$count = (int) ( $row['metricValues'][0]['value'] ?? 0 );
				if ( isset( $ga_data['current'][ $page ] ) ) {
					$ga_data['current'][ $page ]['form_fills'] = $count;
				}
			}
			$this->log_event( 'bulk_forms_success', 'GA4 form submissions collected' );
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_forms_error', 'GA4 form submissions error', array( 'error' => $e->getMessage() ) );
		}

		// ---- AI answer-engine referrals --------------------------------
		// Sessions arriving from AI assistants — the measurable outcome of
		// answer-engine visibility.
		try {
			$ai_sources  = array( 'chatgpt', 'openai', 'perplexity', 'copilot', 'gemini', 'claude', 'poe.com', 'you.com' );
			$expressions = array();
			foreach ( $ai_sources as $ai_source ) {
				$expressions[] = array(
					'filter' => array(
						'fieldName'    => 'sessionSource',
						'stringFilter' => array(
							'value'         => $ai_source,
							'matchType'     => 'CONTAINS',
							'caseSensitive' => false,
						),
					),
				);
			}

			$body     = wp_json_encode( array(
				'dateRanges'      => array( array( 'startDate' => '30daysAgo', 'endDate' => 'today' ) ),
				'dimensions'      => array( array( 'name' => 'pagePath' ) ),
				'metrics'         => array( array( 'name' => 'sessions' ) ),
				'dimensionFilter' => array( 'orGroup' => array( 'expressions' => $expressions ) ),
				'limit'           => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GA4 AI referrals' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page     = cpanalyzer_normalize_path( $row['dimensionValues'][0]['value'] ?? '' );
				$sessions = (int) ( $row['metricValues'][0]['value'] ?? 0 );
				if ( isset( $ga_data['current'][ $page ] ) ) {
					$ga_data['current'][ $page ]['ai_sessions'] = ( $ga_data['current'][ $page ]['ai_sessions'] ?? 0 ) + $sessions;
				}
			}
			$this->log_event( 'bulk_ai_referrals', 'GA4 AI referral sessions collected' );
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_ai_referrals_error', 'GA4 AI referrals error', array( 'error' => $e->getMessage() ) );
		}

		$ga_data = $this->recover_missing_ga4_metrics( $ga_data, $endpoint, $headers );

		return $ga_data;
	}

	/**
	 * Guaranteed fallback: any uploaded URL the bulk GA4 pull missed gets a
	 * targeted, filtered request of its own. Protects the homepage and other
	 * key pages when a site has more paths than the bulk row window.
	 *
	 * @param array  $ga_data  Bulk GA4 data ({current, previous}).
	 * @param string $endpoint GA4 runReport endpoint.
	 * @param array  $headers  Auth headers.
	 * @return array
	 */
	private function recover_missing_ga4_metrics( $ga_data, $endpoint, $headers ) {
		$uploaded = $this->get_uploaded_url_lookup();
		if ( empty( $uploaded ) ) {
			return $ga_data;
		}

		$missing = array();
		foreach ( array_keys( $uploaded ) as $path ) {
			if ( ! isset( $ga_data['current'][ $path ] ) ) {
				$missing[] = $path;
			}
		}

		if ( empty( $missing ) ) {
			return $ga_data;
		}

		$missing = array_slice( $missing, 0, (int) apply_filters( 'cpanalyzer_metric_recovery_limit', 5 ) );

		foreach ( $missing as $path ) {
			$page_filter = array(
				'filter' => array(
					'fieldName'    => 'pagePath',
					'stringFilter' => array( 'value' => $path, 'matchType' => 'EXACT' ),
				),
			);

			try {
				$body     = wp_json_encode( array(
					'dateRanges'      => array( array( 'startDate' => '30daysAgo', 'endDate' => 'today' ) ),
					'dimensions'      => array( array( 'name' => 'pagePath' ) ),
					'metrics'         => array(
						array( 'name' => 'screenPageViews' ),
						array( 'name' => 'activeUsers' ),
						array( 'name' => 'engagementRate' ),
						array( 'name' => 'averageSessionDuration' ),
					),
					'dimensionFilter' => $page_filter,
					'limit'           => 1,
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 15 ) );
				$data     = $this->parse_google_response( $response, 'GA4 metric recovery' );
				$m        = $data['rows'][0]['metricValues'] ?? null;

				if ( null === $m ) {
					continue; // Genuinely no traffic for this path.
				}

				$ga_data['current'][ $path ] = array(
					'views'      => (int) ( $m[0]['value'] ?? 0 ),
					'uniques'    => (int) ( $m[1]['value'] ?? 0 ),
					'engagement' => (float) ( $m[2]['value'] ?? 0 ),
					'duration'   => (float) ( $m[3]['value'] ?? 0 ),
					'form_fills' => 0,
					'views_prev' => 0,
				);

				$body     = wp_json_encode( array(
					'dateRanges'      => array( array( 'startDate' => '60daysAgo', 'endDate' => '31daysAgo' ) ),
					'dimensions'      => array( array( 'name' => 'pagePath' ) ),
					'metrics'         => array(
						array( 'name' => 'screenPageViews' ),
						array( 'name' => 'engagementRate' ),
					),
					'dimensionFilter' => $page_filter,
					'limit'           => 1,
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 15 ) );
				$data     = $this->parse_google_response( $response, 'GA4 metric recovery (previous)' );
				$m        = $data['rows'][0]['metricValues'] ?? null;

				if ( null !== $m ) {
					$ga_data['current'][ $path ]['views_prev']      = (int) ( $m[0]['value'] ?? 0 );
					$ga_data['current'][ $path ]['engagement_prev'] = (float) ( $m[1]['value'] ?? 0 );
				}

				$this->log_event( 'ga4_metric_recovery', 'Recovered GA4 metrics for missed page', array( 'path' => $path ) );
			} catch ( Exception $e ) {
				$this->log_event( 'ga4_metric_recovery_error', 'GA4 metric recovery failed', array( 'path' => $path, 'error' => $e->getMessage() ) );
			}
		}

		return $ga_data;
	}

	/**
	 * Collect bulk GSC data via OAuth Bearer token — no SDK.
	 *
	 * @return array  Keyed by normalised page path.
	 */
	private function collect_bulk_gsc_data() {
		$oauth    = new CPAP_Google_OAuth();
		$token    = $oauth->get_valid_access_token();
		$site_url = get_option( 'cpanalyzer_site_url', '' );

		$gsc_data = array();

		if ( empty( $token ) || empty( $site_url ) ) {
			$this->log_event( 'bulk_gsc', 'OAuth token or Search Console site URL missing.' );
			return $gsc_data;
		}

		$endpoint = 'https://searchconsole.googleapis.com/webmasters/v3/sites/'
		            . rawurlencode( $site_url ) . '/searchAnalytics/query';
		$headers  = array(
			'Authorization' => 'Bearer ' . $token,
			'Content-Type'  => 'application/json',
		);

		// ---- Current period ------------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'startDate'  => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
				'endDate'    => gmdate( 'Y-m-d' ),
				'dimensions' => array( 'page' ),
				'rowLimit'   => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GSC current' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page = $this->normalize_own_host_path( $row['keys'][0] ?? '' );
				if ( '' === $page ) {
					continue;
				}
				$gsc_data[ $page ] = array(
					'impressions'      => (int) ( $row['impressions'] ?? 0 ),
					'clicks'           => (int) ( $row['clicks'] ?? 0 ),
					'ctr'              => round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 ),
					'position'         => round( (float) ( $row['position'] ?? 0 ), 1 ),
					'impressions_prev' => 0,
					'clicks_prev'      => 0,
					'ctr_prev'         => 0,
					'position_prev'    => 0,
					'keywords'         => array(),
				);
			}
			$this->log_event( 'bulk_gsc_current', 'GSC current period collected', array( 'pages' => count( $gsc_data ) ) );
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_gsc_error', 'GSC current period error', array( 'error' => $e->getMessage() ) );
		}

		// ---- Previous period -----------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'startDate'  => gmdate( 'Y-m-d', strtotime( '-60 days' ) ),
				'endDate'    => gmdate( 'Y-m-d', strtotime( '-31 days' ) ),
				'dimensions' => array( 'page' ),
				'rowLimit'   => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GSC previous' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page = $this->normalize_own_host_path( $row['keys'][0] ?? '' );
				if ( '' !== $page && isset( $gsc_data[ $page ] ) ) {
					$gsc_data[ $page ]['impressions_prev'] = (int) ( $row['impressions'] ?? 0 );
					$gsc_data[ $page ]['clicks_prev']      = (int) ( $row['clicks'] ?? 0 );
					$gsc_data[ $page ]['ctr_prev']         = round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 );
					$gsc_data[ $page ]['position_prev']    = round( (float) ( $row['position'] ?? 0 ), 1 );
				}
			}
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_gsc_prev_error', 'GSC previous period error', array( 'error' => $e->getMessage() ) );
		}

		// ---- Keywords per page ---------------------------------------------
		try {
			$body     = wp_json_encode( array(
				'startDate'  => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
				'endDate'    => gmdate( 'Y-m-d' ),
				'dimensions' => array( 'page', 'query' ),
				'rowLimit'   => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GSC keywords' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page    = $this->normalize_own_host_path( $row['keys'][0] ?? '' );
				$keyword = sanitize_text_field( $row['keys'][1] ?? '' );
				if ( '' !== $page && isset( $gsc_data[ $page ] ) && ! empty( $keyword ) ) {
					$gsc_data[ $page ]['keywords'][] = array(
						'keyword'     => $keyword,
						'impressions' => (int) ( $row['impressions'] ?? 0 ),
						'clicks'      => (int) ( $row['clicks'] ?? 0 ),
						'ctr'         => round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 ),
						'position'    => round( (float) ( $row['position'] ?? 0 ), 1 ),
					);
				}
			}

			foreach ( $gsc_data as &$page_data ) {
				if ( ! empty( $page_data['keywords'] ) ) {
					usort( $page_data['keywords'], static function ( $a, $b ) {
						$clicks_diff = (int) ( $b['clicks'] ?? 0 ) - (int) ( $a['clicks'] ?? 0 );
						if ( $clicks_diff !== 0 ) {
							return $clicks_diff;
						}
						return (int) ( $b['impressions'] ?? 0 ) - (int) ( $a['impressions'] ?? 0 );
					} );
					// Keep 10: question-intent queries beyond the top 3 feed
					// the answer-engine gap detection.
					$page_data['keywords'] = array_slice( $page_data['keywords'], 0, 10 );
				}
			}
			unset( $page_data );
			$this->log_event( 'bulk_gsc_keywords', 'GSC keywords collected' );
		} catch ( Exception $e ) {
			$this->log_event( 'bulk_keywords_error', 'GSC keywords error', array( 'error' => $e->getMessage() ) );
		}

		$gsc_data = $this->recover_missing_gsc_metrics( $gsc_data, $endpoint, $headers, $site_url );
		$gsc_data = $this->recover_missing_gsc_keywords( $gsc_data, $endpoint, $headers, $site_url );

		// ---- Long-window trend + cannibalization (transient-cached) --------
		$trend_map = $this->collect_gsc_trend_data( $endpoint, $headers );
		foreach ( $trend_map as $page => $buckets ) {
			if ( isset( $gsc_data[ $page ] ) ) {
				$gsc_data[ $page ]['trend'] = $buckets;
			}
		}

		$cannibalization_map = $this->collect_gsc_cannibalization( $endpoint, $headers );
		foreach ( $cannibalization_map as $page => $conflicts ) {
			if ( isset( $gsc_data[ $page ] ) ) {
				$gsc_data[ $page ]['cannibalization'] = $conflicts;
			}
		}

		$yoy_map = $this->collect_gsc_yoy_data( $endpoint, $headers );
		foreach ( $yoy_map as $page => $yoy ) {
			if ( isset( $gsc_data[ $page ] ) ) {
				$gsc_data[ $page ]['clicks_yoy']      = $yoy['clicks'];
				$gsc_data[ $page ]['impressions_yoy'] = $yoy['impressions'];
			}
		}

		return $gsc_data;
	}

	/**
	 * Collect the SAME 30-day window one year back (364 days, to keep
	 * weekdays aligned) — the seasonality control. A drop that matches last
	 * year's numbers for this window is an expected pattern, not decay.
	 * Cached for 6 hours.
	 *
	 * @param string $endpoint Search Console query endpoint.
	 * @param array  $headers  Auth headers.
	 * @return array<string,array{clicks:int,impressions:int}> Keyed by normalized path.
	 */
	private function collect_gsc_yoy_data( $endpoint, $headers ) {
		$cached = get_transient( 'cpanalyzer_gsc_yoy_data' );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$yoy_map = array();

		try {
			$body     = wp_json_encode( array(
				'startDate'  => gmdate( 'Y-m-d', strtotime( '-394 days' ) ),
				'endDate'    => gmdate( 'Y-m-d', strtotime( '-364 days' ) ),
				'dimensions' => array( 'page' ),
				'rowLimit'   => 2500,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GSC year-over-year' );

			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$page = $this->normalize_own_host_path( $row['keys'][0] ?? '' );
				if ( '' !== $page ) {
					$yoy_map[ $page ] = array(
						'clicks'      => (int) ( $row['clicks'] ?? 0 ),
						'impressions' => (int) ( $row['impressions'] ?? 0 ),
					);
				}
			}

			set_transient( 'cpanalyzer_gsc_yoy_data', $yoy_map, 6 * HOUR_IN_SECONDS );
			$this->log_event( 'gsc_yoy_collected', 'GSC year-over-year window collected', array( 'pages' => count( $yoy_map ) ) );
		} catch ( Exception $e ) {
			// Sites younger than a year simply have no rows here — fine.
			$this->log_event( 'gsc_yoy_error', 'GSC year-over-year error', array( 'error' => $e->getMessage() ) );
		}

		return $yoy_map;
	}

	/**
	 * Collect six 28-day GSC click buckets per page (oldest first) so decay
	 * can be judged as a pattern instead of a single two-period comparison.
	 * Cached for 6 hours — batch runs re-enter this method once per chunk.
	 *
	 * @param string $endpoint Search Console query endpoint.
	 * @param array  $headers  Auth headers.
	 * @return array<string,int[]> Keyed by normalized path.
	 */
	private function collect_gsc_trend_data( $endpoint, $headers ) {
		$cached = get_transient( 'cpanalyzer_gsc_trend_data' );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$buckets_per_page = array();
		$bucket_count     = 6;
		$successful       = 0;

		for ( $bucket = 0; $bucket < $bucket_count; $bucket++ ) {
			// Bucket 0 is the oldest window; bucket 5 ends yesterday.
			$days_back_start = ( $bucket_count - $bucket ) * 28;
			$days_back_end   = ( $bucket_count - $bucket - 1 ) * 28 + 1;

			try {
				$body     = wp_json_encode( array(
					'startDate'  => gmdate( 'Y-m-d', strtotime( "-{$days_back_start} days" ) ),
					'endDate'    => gmdate( 'Y-m-d', strtotime( "-{$days_back_end} days" ) ),
					'dimensions' => array( 'page' ),
					'rowLimit'   => 2500,
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
				$data     = $this->parse_google_response( $response, 'GSC trend bucket ' . $bucket );

				foreach ( ( $data['rows'] ?? array() ) as $row ) {
					$page = $this->normalize_own_host_path( $row['keys'][0] ?? '' );
					if ( '' === $page ) {
						continue;
					}
					if ( ! isset( $buckets_per_page[ $page ] ) ) {
						$buckets_per_page[ $page ] = array_fill( 0, $bucket_count, 0 );
					}
					$buckets_per_page[ $page ][ $bucket ] += (int) ( $row['clicks'] ?? 0 );
				}
				$successful++;
			} catch ( Exception $e ) {
				$this->log_event( 'gsc_trend_error', 'GSC trend bucket error', array( 'bucket' => $bucket, 'error' => $e->getMessage() ) );
			}
		}

		// A partial series would produce false "declines" where buckets are
		// simply missing — only keep and cache a complete pull.
		if ( $successful < $bucket_count ) {
			return array();
		}

		set_transient( 'cpanalyzer_gsc_trend_data', $buckets_per_page, 6 * HOUR_IN_SECONDS );
		$this->log_event( 'gsc_trend_collected', 'GSC 6-period click trend collected', array( 'pages' => count( $buckets_per_page ) ) );

		return $buckets_per_page;
	}

	/**
	 * Detect keyword cannibalization: queries where two or more of the site's
	 * own pages split meaningful clicks over the last 90 days. Cached 6 hours.
	 *
	 * @param string $endpoint Search Console query endpoint.
	 * @param array  $headers  Auth headers.
	 * @return array<string,array> Keyed by normalized path; up to 3 conflicts each.
	 */
	private function collect_gsc_cannibalization( $endpoint, $headers ) {
		$cached = get_transient( 'cpanalyzer_gsc_cannibalization' );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$per_page = array();

		try {
			$body     = wp_json_encode( array(
				'startDate'  => gmdate( 'Y-m-d', strtotime( '-90 days' ) ),
				'endDate'    => gmdate( 'Y-m-d' ),
				'dimensions' => array( 'query', 'page' ),
				'rowLimit'   => 5000,
			) );
			$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 30 ) );
			$data     = $this->parse_google_response( $response, 'GSC cannibalization' );

			$by_query = array();
			foreach ( ( $data['rows'] ?? array() ) as $row ) {
				$query = sanitize_text_field( $row['keys'][0] ?? '' );
				$page  = $this->normalize_own_host_path( $row['keys'][1] ?? '' );
				if ( '' === $query || '' === $page ) {
					continue;
				}

				$clicks      = (int) ( $row['clicks'] ?? 0 );
				$impressions = (int) ( $row['impressions'] ?? 0 );

				// Only pages with real presence on the query can cannibalize.
				if ( $clicks < 3 && $impressions < 100 ) {
					continue;
				}

				$by_query[ $query ][] = array(
					'page'        => $page,
					'clicks'      => $clicks,
					'impressions' => $impressions,
					'position'    => round( (float) ( $row['position'] ?? 0 ), 1 ),
				);
			}

			foreach ( $by_query as $query => $pages ) {
				if ( count( $pages ) < 2 ) {
					continue;
				}

				$total_clicks      = array_sum( wp_list_pluck( $pages, 'clicks' ) );
				$total_impressions = array_sum( wp_list_pluck( $pages, 'impressions' ) );
				if ( $total_clicks < 10 && $total_impressions < 500 ) {
					continue;
				}

				usort( $pages, static function ( $a, $b ) {
					return $b['clicks'] - $a['clicks'];
				} );

				foreach ( $pages as $entry ) {
					if ( ! isset( $per_page[ $entry['page'] ] ) ) {
						$per_page[ $entry['page'] ] = array();
					}
					if ( count( $per_page[ $entry['page'] ] ) >= 3 ) {
						continue;
					}

					$competing = array();
					foreach ( $pages as $other ) {
						if ( $other['page'] !== $entry['page'] && count( $competing ) < 3 ) {
							$competing[] = array(
								'page'     => $other['page'],
								'clicks'   => $other['clicks'],
								'position' => $other['position'],
							);
						}
					}

					$per_page[ $entry['page'] ][] = array(
						'query'        => $query,
						'own_clicks'   => $entry['clicks'],
						'own_position' => $entry['position'],
						'competing'    => $competing,
					);
				}
			}

			set_transient( 'cpanalyzer_gsc_cannibalization', $per_page, 6 * HOUR_IN_SECONDS );
			$this->log_event( 'gsc_cannibalization_collected', 'GSC cannibalization scan complete', array( 'affected_pages' => count( $per_page ) ) );
		} catch ( Exception $e ) {
			$this->log_event( 'gsc_cannibalization_error', 'GSC cannibalization error', array( 'error' => $e->getMessage() ) );
		}

		return $per_page;
	}

	/**
	 * Guaranteed fallback: any uploaded URL the bulk GSC pull missed gets a
	 * targeted request filtered to its exact URL — current and previous
	 * periods. Mirrors the GA4 metric recovery.
	 *
	 * @param array  $gsc_data Bulk GSC data keyed by normalized path.
	 * @param string $endpoint Search Console query endpoint.
	 * @param array  $headers  Auth headers.
	 * @param string $site_url Configured Search Console property.
	 * @return array
	 */
	private function recover_missing_gsc_metrics( $gsc_data, $endpoint, $headers, $site_url ) {
		$uploaded = $this->get_uploaded_url_lookup();
		if ( empty( $uploaded ) ) {
			return $gsc_data;
		}

		$missing = array();
		foreach ( array_keys( $uploaded ) as $path ) {
			if ( ! isset( $gsc_data[ $path ] ) ) {
				$missing[] = $path;
			}
		}

		if ( empty( $missing ) ) {
			return $gsc_data;
		}

		$missing = array_slice( $missing, 0, (int) apply_filters( 'cpanalyzer_metric_recovery_limit', 5 ) );

		foreach ( $missing as $path ) {
			$full_url     = $this->build_full_url( $path, $site_url );
			$page_filters = array(
				array(
					'filters' => array(
						array(
							'dimension'  => 'page',
							'operator'   => 'equals',
							'expression' => $full_url,
						),
					),
				),
			);

			try {
				$body     = wp_json_encode( array(
					'startDate'             => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
					'endDate'               => gmdate( 'Y-m-d' ),
					'dimensions'            => array( 'page' ),
					'rowLimit'              => 1,
					'dimensionFilterGroups' => $page_filters,
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 15 ) );
				$data     = $this->parse_google_response( $response, 'GSC metric recovery' );
				$row      = $data['rows'][0] ?? null;

				if ( null === $row ) {
					continue; // Genuinely no search presence for this URL.
				}

				$gsc_data[ $path ] = array(
					'impressions'      => (int) ( $row['impressions'] ?? 0 ),
					'clicks'           => (int) ( $row['clicks'] ?? 0 ),
					'ctr'              => round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 ),
					'position'         => round( (float) ( $row['position'] ?? 0 ), 1 ),
					'impressions_prev' => 0,
					'clicks_prev'      => 0,
					'ctr_prev'         => 0,
					'position_prev'    => 0,
					'keywords'         => array(),
				);

				$body     = wp_json_encode( array(
					'startDate'             => gmdate( 'Y-m-d', strtotime( '-60 days' ) ),
					'endDate'               => gmdate( 'Y-m-d', strtotime( '-31 days' ) ),
					'dimensions'            => array( 'page' ),
					'rowLimit'              => 1,
					'dimensionFilterGroups' => $page_filters,
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 15 ) );
				$data     = $this->parse_google_response( $response, 'GSC metric recovery (previous)' );
				$row      = $data['rows'][0] ?? null;

				if ( null !== $row ) {
					$gsc_data[ $path ]['impressions_prev'] = (int) ( $row['impressions'] ?? 0 );
					$gsc_data[ $path ]['clicks_prev']      = (int) ( $row['clicks'] ?? 0 );
					$gsc_data[ $path ]['ctr_prev']         = round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 );
					$gsc_data[ $path ]['position_prev']    = round( (float) ( $row['position'] ?? 0 ), 1 );
				}

				$this->log_event( 'gsc_metric_recovery', 'Recovered GSC metrics for missed page', array( 'path' => $path ) );
			} catch ( Exception $e ) {
				$this->log_event( 'gsc_metric_recovery_error', 'GSC metric recovery failed', array( 'path' => $path, 'error' => $e->getMessage() ) );
			}
		}

		return $gsc_data;
	}

	/**
	 * Recover missing GSC keywords for uploaded URLs that have page metrics but no query rows.
	 *
	 * @param array  $gsc_data  Existing GSC data keyed by normalized path.
	 * @param string $endpoint  Search Console query endpoint.
	 * @param array  $headers   Auth headers for Search Console.
	 * @param string $site_url  Configured Search Console property.
	 * @return array
	 */
	private function recover_missing_gsc_keywords( $gsc_data, $endpoint, $headers, $site_url ) {
		$uploaded_urls  = $this->get_uploaded_url_lookup();
		$missing_pages  = array();
		$recovered      = 0;
		$failed_pages   = array();
		$recovery_limit = (int) apply_filters( 'cpanalyzer_gsc_keyword_recovery_limit', 0 );

		if ( empty( $gsc_data ) || empty( $uploaded_urls ) ) {
			return $gsc_data;
		}

		foreach ( $gsc_data as $page => $page_data ) {
			$has_metrics = (int) ( $page_data['impressions'] ?? 0 ) > 0 || (int) ( $page_data['clicks'] ?? 0 ) > 0;
			if ( empty( $uploaded_urls[ $page ] ) || ! $has_metrics || ! empty( $page_data['keywords'] ) ) {
				continue;
			}

			$missing_pages[] = $page;
		}

		if ( empty( $missing_pages ) ) {
			return $gsc_data;
		}

		$skipped_pages = 0;
		if ( $recovery_limit > 0 && count( $missing_pages ) > $recovery_limit ) {
			$skipped_pages = count( $missing_pages ) - $recovery_limit;
			$missing_pages = array_slice( $missing_pages, 0, $recovery_limit );
		}

		foreach ( $missing_pages as $page ) {
			$full_url = $this->build_full_url( $page, $site_url );

			try {
				$body     = wp_json_encode( array(
					'startDate'             => gmdate( 'Y-m-d', strtotime( '-30 days' ) ),
					'endDate'               => gmdate( 'Y-m-d' ),
					'dimensions'            => array( 'query' ),
					'rowLimit'              => 10,
					'dimensionFilterGroups' => array(
						array(
							'filters' => array(
								array(
									'dimension'  => 'page',
									'operator'   => 'equals',
									'expression' => $full_url,
								),
							),
						),
					),
				) );
				$response = wp_remote_post( $endpoint, array( 'headers' => $headers, 'body' => $body, 'timeout' => 15 ) );
				$data     = $this->parse_google_response( $response, 'GSC keyword recovery' );
				$keywords = array();

				foreach ( ( $data['rows'] ?? array() ) as $row ) {
					$keyword = sanitize_text_field( $row['keys'][0] ?? '' );
					if ( empty( $keyword ) ) {
						continue;
					}

					$keywords[] = array(
						'keyword'     => $keyword,
						'impressions' => (int) ( $row['impressions'] ?? 0 ),
						'clicks'      => (int) ( $row['clicks'] ?? 0 ),
						'ctr'         => round( (float) ( $row['ctr'] ?? 0 ) * 100, 2 ),
						'position'    => round( (float) ( $row['position'] ?? 0 ), 1 ),
					);
				}

				if ( empty( $keywords ) ) {
					continue;
				}

				usort( $keywords, static function ( $a, $b ) {
					$clicks_diff = (int) ( $b['clicks'] ?? 0 ) - (int) ( $a['clicks'] ?? 0 );
					if ( $clicks_diff !== 0 ) {
						return $clicks_diff;
					}
					return (int) ( $b['impressions'] ?? 0 ) - (int) ( $a['impressions'] ?? 0 );
				} );

				$gsc_data[ $page ]['keywords'] = array_slice( $keywords, 0, 3 );
				++$recovered;
			} catch ( Exception $e ) {
				$failed_pages[ $page ] = $e->getMessage();
			}
		}

		$this->log_event(
			'bulk_gsc_keyword_recovery',
			'Completed targeted GSC keyword recovery for missing-keyword URLs.',
			array(
				'candidates' => count( $missing_pages ) + $skipped_pages,
				'attempted'  => count( $missing_pages ),
				'recovered'  => $recovered,
				'skipped'    => $skipped_pages,
				'failed'     => count( $failed_pages ),
			)
		);

		if ( ! empty( $failed_pages ) ) {
			$this->log_event(
				'bulk_gsc_keyword_recovery_errors',
				'Some targeted GSC keyword recovery requests failed.',
				array(
					'pages' => $failed_pages,
				)
			);
		}

		return $gsc_data;
	}

	/**
	 * Parse a Google REST API response from wp_remote_post/get.
	 * Throws Exception on error so callers can catch cleanly.
	 *
	 * @param  mixed  $response
	 * @param  string $context
	 * @return array
	 * @throws Exception
	 */
	private function parse_google_response( $response, $context = 'API' ) {
		if ( is_wp_error( $response ) ) {
			throw new Exception( esc_html( $context ) . ': ' . esc_html( $response->get_error_message() ) );
		}
		$code = (int) wp_remote_retrieve_response_code( $response );
		$body = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( $code < 200 || $code >= 300 ) {
			$msg = isset( $body['error']['message'] ) ? $body['error']['message'] : "HTTP {$code}";
			throw new Exception( esc_html( $context ) . ': ' . esc_html( $msg ) );
		}
		return is_array( $body ) ? $body : array();
	}


	/**
	 * Attach decay-type diagnosis (and SERP features when warranted) to a
	 * page's GSC row. Runs once per URL at processing time so the result is
	 * persisted with the row and reused by the UI and the AI payload.
	 *
	 * @param string $url      Uploaded URL path.
	 * @param array  $ga_data  GA4 metrics for the URL.
	 * @param array  $gsc_data GSC metrics for the URL.
	 * @return array Enriched GSC row.
	 */
	private function enrich_url_intelligence( $url, $ga_data, $gsc_data ) {
		if ( ! function_exists( 'cpanalyzer_classify_decay_type' ) ) {
			return $gsc_data;
		}

		$post_modified_ts = 0;
		$post_id          = url_to_postid( home_url( $url ) );
		if ( $post_id > 0 ) {
			$modified_gmt = get_post_field( 'post_modified_gmt', $post_id );
			if ( ! empty( $modified_gmt ) && '0000-00-00 00:00:00' !== $modified_gmt ) {
				$post_modified_ts = (int) strtotime( $modified_gmt );
			}
		}

		$diagnosis = cpanalyzer_classify_decay_type( $ga_data, $gsc_data, $post_modified_ts );

		// Live SERP check only for diagnoses it can sharpen — zero-click
		// confirmation and intent-drift detection.
		if ( in_array( $diagnosis['type'], array( 'zero_click', 'ranking_decay', 'demand_decay', 'mixed' ), true ) ) {
			$features = $this->maybe_get_serp_features( $url, $gsc_data );
			if ( ! empty( $features ) ) {
				$gsc_data['serp_features'] = $features;
				$diagnosis                 = cpanalyzer_refine_decay_with_serp( $diagnosis, $features );
			}
		}

		$gsc_data['decay_diagnosis'] = $diagnosis;

		return $gsc_data;
	}

	/**
	 * Let the live page scrape override the metrics-based diagnosis —
	 * a noindex tag or foreign canonical IS the root cause when present.
	 *
	 * @param array $gsc_data     GSC row carrying decay_diagnosis.
	 * @param array $content_data Scraped content analysis.
	 * @return array
	 */
	private function apply_post_scrape_signals( $gsc_data, $content_data ) {
		if ( function_exists( 'cpanalyzer_apply_technical_signals' ) && ! empty( $gsc_data['decay_diagnosis'] ) ) {
			$gsc_data['decay_diagnosis'] = cpanalyzer_apply_technical_signals( $gsc_data['decay_diagnosis'], (array) $content_data );
		}
		return $gsc_data;
	}

	/**
	 * Fetch SERP features for a page's top query, budgeted at 10 lookups per
	 * batch chunk (results are transient-cached for 7 days per keyword, so
	 * repeat runs rarely spend real API credits).
	 *
	 * @param string $url      Uploaded URL path.
	 * @param array  $gsc_data GSC metrics with keywords.
	 * @return array SERP features, or empty array when unavailable.
	 */
	private function maybe_get_serp_features( $url, $gsc_data ) {
		static $serp_lookups = 0;

		if ( $serp_lookups >= 10 || ! class_exists( 'CPAP_Serper_Collector' ) ) {
			return array();
		}

		$serper = new CPAP_Serper_Collector();
		if ( ! $serper->is_configured() ) {
			return array();
		}

		$keyword = sanitize_text_field( $gsc_data['keywords'][0]['keyword'] ?? '' );
		if ( '' === $keyword ) {
			return array();
		}

		$serp_lookups++;
		$features = $serper->get_serp_features( $keyword, $url );

		return is_wp_error( $features ) ? array() : $features;
	}

	private function prepare_ai_payload( $url, $ga_data, $gsc_data, $content_data ) {
		$payload = array(
			'url'             => $url,
			'flag'            => $this->determine_flag( $ga_data, $gsc_data, $content_data ),
			'primary_keyword' => sanitize_text_field( $gsc_data['keywords'][0]['keyword'] ?? ( $gsc_data['keywords'][0]['query'] ?? '' ) ),
			'ga_metrics'      => array(
				'views'          => $ga_data['views'] ?? 0,
				'uniques'        => $ga_data['uniques'] ?? 0,
				'engagement'     => $ga_data['engagement'] ?? 0,
				'duration'       => $ga_data['duration'] ?? 0,
				'form_fills'     => $ga_data['form_fills'] ?? 0,
				'views_previous' => $ga_data['views_prev'] ?? 0,
			),
			'gsc_metrics'     => array(
				'impressions'      => $gsc_data['impressions'] ?? 0,
				'clicks'           => $gsc_data['clicks'] ?? 0,
				'ctr'              => $gsc_data['ctr'] ?? 0,
				'position'         => $gsc_data['position'] ?? 0,
				'impressions_prev' => $gsc_data['impressions_prev'] ?? 0,
				'clicks_prev'      => $gsc_data['clicks_prev'] ?? 0,
				'position_prev'    => $gsc_data['position_prev'] ?? 0,
				'top_keywords'     => array_slice( (array) ( $gsc_data['keywords'] ?? array() ), 0, 5 ),
			),
		);

		if ( ! empty( $gsc_data['decay_diagnosis'] ) && 'none' !== ( $gsc_data['decay_diagnosis']['type'] ?? 'none' ) ) {
			$payload['decay_diagnosis'] = $gsc_data['decay_diagnosis'];
		}

		if ( ! empty( $gsc_data['cannibalization'] ) ) {
			$payload['cannibalization'] = array_slice( (array) $gsc_data['cannibalization'], 0, 3 );
		}

		if ( ! empty( $gsc_data['serp_features'] ) ) {
			$payload['serp_features'] = $gsc_data['serp_features'];
		}

		if ( ! empty( $gsc_data['trend'] ) && function_exists( 'cpanalyzer_analyze_click_trend' ) ) {
			$payload['click_trend'] = cpanalyzer_analyze_click_trend( $gsc_data['trend'] );
		}

		if ( ! empty( $content_data ) ) {
			$payload['content_source'] = $content_data;
		}
		return $payload;
	}

	private function get_ai_suggestions_chunked( $payload, $content_data ) {
		$ai_processor = new CPAP_AI_Processor();

		if ( empty( $payload ) || ! is_array( $payload ) ) {
			return array();
		}

		if ( ! $ai_processor->is_configured() ) {
			$this->log_event( 'ai_key_missing', 'OpenAI API key missing. Skipping AI suggestion.' );
			return array();
		}

		if ( ! empty( $content_data ) && empty( $payload['content_source'] ) ) {
			$payload['content_source'] = $content_data;
		}

		try {
			$actions = $ai_processor->generate_actions_for_payload( array( $payload ) );
			if ( ! empty( $actions[0] ) && is_array( $actions[0] ) ) {
				$this->log_event(
					'ai_success',
					'Received AI suggestions via shared AI processor.',
					array(
						'url' => $payload['url'] ?? '',
					)
				);
				return $actions[0];
			}

			$this->log_event(
				'ai_empty',
				'Shared AI processor returned no suggestions.',
				array(
					'url' => $payload['url'] ?? '',
				)
			);
		} catch ( Exception $e ) {
			$this->log_event( 'ai_exception', 'AI Exception', array( 'error' => $e->getMessage() ) );
		}
		return array();
	}

	private function generate_summary( $ga_data, $gsc_data, $content_data ) {
		$summary = array();
		if ( ! empty( $ga_data ) ) {
			$summary['traffic'] = 'Views: ' . ( $ga_data['views'] ?? 0 ) . ', Engagement: ' . round( ( $ga_data['engagement'] ?? 0 ) * 100, 1 ) . '%';
		}
		if ( ! empty( $gsc_data ) ) {
			$summary['search'] = 'Impressions: ' . ( $gsc_data['impressions'] ?? 0 ) . ', CTR: ' . ( $gsc_data['ctr'] ?? 0 ) . '%';
		}
		if ( ! empty( $content_data ) ) {
			$summary['content'] = 'Words: ' . ( $content_data['word_count'] ?? 0 ) . ', Images: ' . ( $content_data['image_count'] ?? 0 );
		}
		return $summary;
	}

	private function determine_flag( $ga_data, $gsc_data, $content_data ) {
		// Decay diagnosis wins: a classified traffic drop outranks CTR or
		// engagement opportunities. Seasonal patterns are expected, not
		// problems — they never flag.
		$diagnosis_type = $gsc_data['decay_diagnosis']['type'] ?? 'none';
		if ( ! in_array( $diagnosis_type, array( 'none', 'unconfirmed', 'seasonal' ), true ) ) {
			return 'decay';
		}
		if ( ! empty( $gsc_data ) && $gsc_data['impressions'] > 500 && $gsc_data['ctr'] < 2 ) {
			return 'seo';
		}
		if ( ! empty( $ga_data ) && $ga_data['views'] > 800 && $ga_data['engagement'] < 0.3 ) {
			return 'conversion';
		}
		if ( ! empty( $content_data ) && $content_data['word_count'] < 300 ) {
			return 'content';
		}
		return 'review';
	}

	/**
	 * Normalize a GSC page URL to a path — but ONLY for the site's own host.
	 * Domain properties (sc-domain:) return pages from every subdomain, and
	 * a host-blind normalizer lets community.example.com/ overwrite the real
	 * homepage row at '/'. Returns '' for foreign hosts so callers skip them.
	 *
	 * @param string $url_key Full page URL from a GSC row.
	 * @return string Normalized path, or '' when the row belongs to another host.
	 */
	private function normalize_own_host_path( $url_key ) {
		$host = wp_parse_url( strtolower( (string) $url_key ), PHP_URL_HOST );

		if ( null !== $host && '' !== $host ) {
			$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
			if ( preg_replace( '/^www\./', '', $host ) !== preg_replace( '/^www\./', '', $site_host ) ) {
				return '';
			}
		}

		return cpanalyzer_normalize_path( $url_key );
	}

	private function build_full_url( $path, $site_url ) {
		if ( strpos( $site_url, 'sc-domain:' ) === 0 ) {
			return 'https://' . substr( $site_url, 10 ) . $path;
		}
		return rtrim( $site_url, '/' ) . '/' . ltrim( $path, '/' );
	}

	/**
	 * Return uploaded URLs keyed by normalized path for fast lookups.
	 *
	 * @return array<string, bool>
	 */
	private function get_uploaded_url_lookup() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only custom table lookup for targeted GSC recovery.
		$urls = $wpdb->get_col( "SELECT url FROM {$this->table_name}" );
		if ( empty( $urls ) ) {
			return array();
		}

		$lookup = array();
		foreach ( $urls as $url ) {
			$path = cpanalyzer_normalize_path( (string) $url );
			if ( '' !== $path ) {
				$lookup[ $path ] = true;
			}
		}

		return $lookup;
	}

	public function trigger_full_scan() {
		global $wpdb;
		$this->log_event( 'full_scan', 'Triggered full scan of all URLs.' );

		if ( get_option( 'cpanalyzer_enable_auto_scan', 'no' ) !== 'yes' ) {
			$this->log_event( 'auto_scan_disabled', 'Auto scan is disabled. Scan aborted.' );
			return;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk status updates for rescan functionality
		$wpdb->update( $this->table_name, array( 'status' => 'pending' ), array( 'status' => 'success' ) );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Bulk status updates for rescan functionality
		$wpdb->query( $wpdb->prepare( "UPDATE {$this->table_name} SET status = %s WHERE status = %s", 'pending', 'failed' ) );

		if ( ! wp_next_scheduled( 'cpanalyzer_batch_process' ) ) {
			wp_schedule_single_event( time() + 10, 'cpanalyzer_batch_process' );
		}

		update_option( 'cpanalyzer_batch_status', 'running' );
		update_option( 'cpanalyzer_last_full_scan', current_time( 'mysql' ) );
	}

	public function ajax_start_batch_process() {
		check_ajax_referer( 'cpanalyzer_batch_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'content-performance-analyzer' ) );
		}
		$pending_count = $this->get_pending_count();

		$this->log_event( 'batch_ajax', "Admin started batch processing via AJAX. Found {$pending_count} pending URLs." );

		if ( $pending_count == 0 ) {
			wp_send_json_error( array( 'message' => __( 'No pending URLs to process. Upload URLs first.', 'content-performance-analyzer' ) ) );
			return;
		}

		$this->start_processing_now();

		wp_send_json_success(
			array(
				/* translators: %d: number of URLs to process */
				'message'       => sprintf( __( 'Batch processing started for %d URLs. Processing first batch now...', 'content-performance-analyzer' ), $pending_count ),
				'pending_count' => $pending_count,
			)
			);
	}

	/**
	 * Get the current number of pending URLs.
	 *
	 * @return int
	 */
	public function get_pending_count() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table queries for batch processing
		return intval( $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'pending' ) ) );
	}

	/**
	 * Start processing immediately and keep cron scheduled for remaining work.
	 *
	 * @return int Number of pending URLs when processing started.
	 */
	public function start_processing_now() {
		$pending_count = $this->get_pending_count();

		if ( $pending_count <= 0 ) {
			return 0;
		}

		// Clear any existing transients to ensure fresh start
		delete_transient( 'cpanalyzer_batch_running' );
		delete_transient( 'cpanalyzer_batch_start_time' );

		// Schedule immediate processing
		wp_clear_scheduled_hook( 'cpanalyzer_batch_process' );
		wp_schedule_single_event( time() + 2, 'cpanalyzer_batch_process' );

		update_option( 'cpanalyzer_batch_status', 'running' );

		// Process the first chunk in-request so the UI shows progress immediately.
		$this->process_urls_directly( $pending_count );

		return $pending_count;
	}

	public function ajax_get_batch_status() {
		check_ajax_referer( 'cpanalyzer_batch_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'content-performance-analyzer' ) );
		}
		global $wpdb;
		$this->log_event( 'batch_status', 'Admin requested batch status via AJAX.' );
		$stats = array(
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table stats queries
			'total'       => $wpdb->get_var( "SELECT COUNT(*) FROM {$this->table_name}" ),
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table stats queries
			'pending'     => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'pending' ) ),
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table stats queries
			'in_progress' => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'in_progress' ) ),
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table stats queries
			'success'     => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'success' ) ),
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Custom table stats queries
			'failed'      => $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$this->table_name} WHERE status = %s", 'failed' ) ),
			'status'      => get_option( 'cpanalyzer_batch_status', 'idle' ),
			'is_running'  => get_transient( 'cpanalyzer_batch_running' ) ? true : false,
		);
		wp_send_json_success( $stats );
	}

	public function ajax_retry_failed_url() {
		check_ajax_referer( 'cpanalyzer_batch_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'content-performance-analyzer' ) );
		}
		$url_id = isset( $_POST['url_id'] ) ? absint( $_POST['url_id'] ) : 0;
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin retry functionality for failed URLs
		$wpdb->update(
			$this->table_name,
			array(
				'status'        => 'pending',
				'error_message' => null,
			),
			array( 'id' => $url_id )
		);

		$this->log_event( 'retry_url', 'Retrying failed URL', array( 'id' => $url_id ) );

		if ( ! wp_next_scheduled( 'cpanalyzer_batch_process' ) ) {
			wp_schedule_single_event( time() + 5, 'cpanalyzer_batch_process' );
		}
		wp_send_json_success( array( 'message' => __( 'URL queued for retry', 'content-performance-analyzer' ) ) );
	}

	public function ajax_cancel_batch() {
		check_ajax_referer( 'cpanalyzer_batch_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Unauthorized', 'content-performance-analyzer' ) );
		}
		$this->log_event( 'batch_cancel', 'Batch processing cancelled by admin.' );
		wp_clear_scheduled_hook( 'cpanalyzer_batch_process' );
		delete_transient( 'cpanalyzer_batch_running' );
		update_option( 'cpanalyzer_batch_status', 'cancelled' );
		wp_send_json_success( array( 'message' => __( 'Batch processing cancelled', 'content-performance-analyzer' ) ) );
	}

	private function get_settings() {
		return array(
			'property_id' => get_option( 'cpanalyzer_property_id', '' ),
			'site_url'    => get_option( 'cpanalyzer_site_url', '' ),
			'ai_ready'    => cpanalyzer_ai_is_configured(),
		);
	}

	private function validate_settings( $settings ) {
		$oauth = new CPAP_Google_OAuth();
		return $oauth->is_connected()
			&& ! empty( $settings['property_id'] )
			&& ! empty( $settings['site_url'] );
	}

	private function log_event( $event_type, $message, $data = array() ) {
		if ( class_exists( 'CPAP_Core' ) && method_exists( CPAP_Core::get_instance(), 'log_event' ) ) {
			CPAP_Core::get_instance()->log_event( $event_type, $message, $data );
		}
		if ( get_option( 'cpanalyzer_debug_mode', 'no' ) === 'yes' && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			$this->debug_log( sprintf( '[CPA_BATCH] %s: %s', $event_type, $message ) );
		}
	}

	/**
	 * Sync batch processed data to insights display
	 */
	private function sync_to_insights() {
		// Ensure admin class is loaded
		if ( ! class_exists( 'CPAP_Admin' ) ) {
			require_once CPANALYZER_PLUGIN_DIR . 'includes/class-cpa-admin.php';
		}

		$admin = new CPAP_Admin();
		$admin->sync_batch_data_to_insights();

		$this->log_event( 'batch_sync', 'Synced batch data to insights display.' );
	}

	/**
	 * Process URLs directly without relying on cron
	 */
	private function process_urls_directly( $total_count ) {
		global $wpdb;

		// Process ALL URLs immediately since WordPress cron isn't working
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Manual trigger processing
		$immediate_urls = $wpdb->get_results(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is class property, safe
				"SELECT * FROM {$this->table_name} WHERE status = 'pending' ORDER BY priority ASC, id ASC LIMIT %d",
				$total_count > 20 ? 20 : $total_count // Process max 20 at once
			)
		);

		if ( empty( $immediate_urls ) ) {
			return;
		}

		// Collect bulk GA4 and GSC data first (like working version)
		$all_ga_data  = array();
		$all_gsc_data = array();

		try {
			$all_ga_data  = $this->collect_bulk_ga4_data();
			$all_gsc_data = $this->collect_bulk_gsc_data();
			$this->log_event(
				'direct_bulk_collected',
				'Successfully collected bulk GA4/GSC data for direct processing',
				array(
					'ga_urls'  => count( $all_ga_data['current'] ),
					'gsc_urls' => count( $all_gsc_data ),
				)
			);
		} catch ( Exception $e ) {
			$this->log_event( 'direct_bulk_error', 'Error collecting bulk data for direct processing', array( 'error' => $e->getMessage() ) );
		}

		// Initialize PSI collector if enabled - FORCED LOGGING
		$psi_collector = null;
		$psi_enabled   = get_option( 'cpanalyzer_pagespeed_enable', 'no' );
		$psi_api_key   = get_option( 'cpanalyzer_pagespeed_api_key', '' );

		$this->debug_log( "[CPANALYZER BATCH DIRECT] PSI initialization - enabled: $psi_enabled, has_key: " . ( ! empty( $psi_api_key ) ? 'yes' : 'no' ) );

		if ( $psi_enabled === 'yes' || $psi_enabled === true ) {
			if ( ! class_exists( 'CPAP_PageSpeed_Collector' ) ) {
				require_once CPANALYZER_PLUGIN_DIR . 'includes/class-cpa-pagespeed-collector.php';
			}
			$psi_collector = new CPAP_PageSpeed_Collector();
			if ( ! $psi_collector->is_enabled() ) {
				$this->debug_log( '[CPANALYZER BATCH DIRECT] PSI enabled but not properly configured - skipping PSI analysis' );
				$psi_collector = null;
			} else {
				$this->debug_log( '[CPANALYZER BATCH DIRECT] PSI collector initialized and ready for direct processing' );
			}
		} else {
			$this->debug_log( "[CPANALYZER BATCH DIRECT] PSI is disabled - enabled setting: $psi_enabled" );
		}

		foreach ( $immediate_urls as $url_record ) {
			try {
				// Mark as in progress
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Status updates for direct processing
				$wpdb->update( $this->table_name, array( 'status' => 'in_progress' ), array( 'id' => $url_record->id ) );

				$url             = $url_record->url;
				$normalized_path = cpanalyzer_normalize_path( $url );

				// Get data from bulk collection
				$ga_data  = $all_ga_data['current'][ $normalized_path ] ?? array();
				$gsc_data = $this->enrich_url_intelligence( $url, $ga_data, $all_gsc_data[ $normalized_path ] ?? array() );

				$content_data = $this->scrape_content_with_psi( $url, $ga_data, $gsc_data, $psi_collector, '[CPANALYZER BATCH DIRECT]' );
				$gsc_data     = $this->apply_post_scrape_signals( $gsc_data, $content_data );

				// Get AI suggestions if we have data
				$ai_suggestions = array();
				if ( ! empty( $ga_data ) || ! empty( $gsc_data ) || ! empty( $content_data ) ) {
					$ai_payload = $this->prepare_ai_payload( $url, $ga_data, $gsc_data, $content_data );
					if ( ! empty( $ai_payload ) ) {
						$ai_suggestions = $this->get_ai_suggestions_chunked( $ai_payload, $content_data );
					}
				}

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Success result updates for direct processing
				$wpdb->update(
					$this->table_name,
					array(
						'status'         => 'success',
						'ga_data'        => wp_json_encode( $ga_data ),
						'gsc_data'       => wp_json_encode( $gsc_data ),
						'content_data'   => wp_json_encode( $content_data ),
						'ai_suggestions' => wp_json_encode( $ai_suggestions ),
						'last_scanned'   => current_time( 'mysql' ),
					),
					array( 'id' => $url_record->id )
				);

				$this->log_event(
					'direct_url_processed',
					'Processed URL successfully',
					array(
						'id'              => $url_record->id,
						'ga_views'        => $ga_data['views'] ?? 0,
						'gsc_impressions' => $gsc_data['impressions'] ?? 0,
					)
				);

			} catch ( Exception $e ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Error status updates for processing failures
				$wpdb->update(
					$this->table_name,
					array(
						'status'        => 'failed',
						'error_message' => $e->getMessage(),
					),
					array( 'id' => $url_record->id )
				);

				$this->log_event(
					'direct_url_error',
					'Error processing URL directly',
					array(
						'id'    => $url_record->id,
						'error' => $e->getMessage(),
					)
				);
			}
		}

		// Sync the processed data
		$this->sync_to_insights();
	}

	/**
	 * Determine if URL should get PSI analysis
	 * Uses enhanced strategy from integration plan: flagged URLs + high-traffic unflagged + stale PSI data
	 */
	private function should_analyze_psi( $url, $ga_data, $gsc_data ) {
		// Always analyze if flagged for any reason
		if ( $this->is_flagged_url( $url, $ga_data, $gsc_data ) ) {
			$this->log_event( 'psi_should_analyze', 'PSI analysis triggered: flagged URL', array( 'url' => $url ) );
			return true;
		}

		// Analyze high-traffic unflagged URLs for performance discovery
		$views       = $ga_data['views'] ?? 0;
		$impressions = $gsc_data['impressions'] ?? 0;

		if ( $views > 100 || $impressions > 500 ) {
			$this->log_event(
				'psi_should_analyze',
				'PSI analysis triggered: high traffic',
				array(
					'url'         => $url,
					'views'       => $views,
					'impressions' => $impressions,
				)
			);
			return true;
		}

		// Analyze URLs with stale PSI data
		if ( $this->has_stale_psi_data( $url ) ) {
			$this->log_event( 'psi_should_analyze', 'PSI analysis triggered: stale data', array( 'url' => $url ) );
			return true;
		}

		$this->log_event(
			'psi_skip',
			'PSI analysis skipped',
			array(
				'url'         => $url,
				'views'       => $views,
				'impressions' => $impressions,
			)
		);
		return false;
	}

	/**
	 * Reprocess a single uploaded URL immediately using the normal collection pipeline.
	 *
	 * @param string $url Normalized uploaded URL path.
	 * @return array|\WP_Error
	 */
	public function reprocess_url_now( $url ) {
		global $wpdb;

		$url = cpanalyzer_normalize_path( $url );
		if ( empty( $url ) ) {
			return new WP_Error( 'invalid_url', __( 'Invalid URL supplied for reprocessing.', 'content-performance-analyzer' ) );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single-row admin reprocessing
		$url_record = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$this->table_name} WHERE url = %s LIMIT 1",
				$url
			)
		);

		if ( ! $url_record ) {
			return new WP_Error( 'url_not_found', __( 'The selected URL could not be found for reprocessing.', 'content-performance-analyzer' ) );
		}

		try {
			$all_ga_data  = $this->collect_bulk_ga4_data();
			$all_gsc_data = $this->collect_bulk_gsc_data();
		} catch ( Exception $e ) {
			$this->log_event( 'single_reprocess_bulk_error', 'Error collecting bulk data for single URL reprocess', array( 'error' => $e->getMessage(), 'url' => $url ) );
			$all_ga_data  = array( 'current' => array() );
			$all_gsc_data = array();
		}

		$psi_collector = $this->build_psi_collector( '[CPANALYZER BATCH SINGLE]' );
		$normalized_path = cpanalyzer_normalize_path( $url_record->url );
		$ga_data  = $all_ga_data['current'][ $normalized_path ] ?? array();
		$gsc_data = $this->enrich_url_intelligence( $url_record->url, $ga_data, $all_gsc_data[ $normalized_path ] ?? array() );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Status updates for single-row reprocessing
		$wpdb->update(
			$this->table_name,
			array(
				'status'        => 'in_progress',
				'error_message' => null,
			),
			array( 'id' => $url_record->id )
		);

		try {
			$content_data = $this->scrape_content_with_psi( $url_record->url, $ga_data, $gsc_data, $psi_collector, '[CPANALYZER BATCH SINGLE]' );
			$gsc_data     = $this->apply_post_scrape_signals( $gsc_data, $content_data );

			$ai_suggestions = array();
			if ( ! empty( $ga_data ) || ! empty( $gsc_data ) || ! empty( $content_data ) ) {
				$ai_payload = $this->prepare_ai_payload( $url_record->url, $ga_data, $gsc_data, $content_data );
				if ( ! empty( $ai_payload ) ) {
					$ai_suggestions = $this->get_ai_suggestions_chunked( $ai_payload, $content_data );
				}
			}

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Result updates for single-row reprocessing
			$wpdb->update(
				$this->table_name,
				array(
					'status'         => 'success',
					'ga_data'        => wp_json_encode( $ga_data ),
					'gsc_data'       => wp_json_encode( $gsc_data ),
					'content_data'   => wp_json_encode( $content_data ),
					'ai_suggestions' => wp_json_encode( $ai_suggestions ),
					'last_scanned'   => current_time( 'mysql' ),
					'scan_result'    => wp_json_encode(
						array(
							'summary'   => $this->generate_summary( $ga_data, $gsc_data, $content_data ),
							'timestamp' => time(),
						)
					),
					'error_message'  => null,
				),
				array( 'id' => $url_record->id )
			);

			$this->sync_to_insights();

			return array(
				'url'       => $url_record->url,
				'has_psi'   => ! empty( $content_data['psi'] ),
				'psi_error' => sanitize_text_field( $content_data['psi_error'] ?? '' ),
			);
		} catch ( Exception $e ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Failure status for single-row reprocessing
			$wpdb->update(
				$this->table_name,
				array(
					'status'        => 'failed',
					'error_message' => $e->getMessage(),
					'last_scanned'  => current_time( 'mysql' ),
				),
				array( 'id' => $url_record->id )
			);

			$this->sync_to_insights();

			return new WP_Error( 'single_reprocess_failed', $e->getMessage() );
		}
	}

	/**
	 * Check if URL is flagged by existing criteria
	 */
	private function is_flagged_url( $url, $ga_data, $gsc_data ) {
		// Performance thresholds (from data collector)
		$thresholds = array(
			'min_impressions'    => 500,
			'low_ctr'            => 2,
			'decay_threshold'    => 25,
			'min_previous_views' => 1000,
			'high_views'         => 800,
			'low_conversions'    => 5,
		);

		// Set defaults
		$ga_data = array_merge(
			array(
				'views'      => 0,
				'views_prev' => 0,
				'form_fills' => 0,
			),
			$ga_data
		);

		$gsc_data = array_merge(
			array(
				'impressions' => 0,
				'ctr'         => 0,
			),
			$gsc_data
		);

		// Check for decay flag
		$decay = 0;
		if ( $ga_data['views_prev'] > 0 ) {
			$decay = round( 100 * ( $ga_data['views_prev'] - $ga_data['views'] ) / $ga_data['views_prev'], 1 );
		}

		if ( $decay >= $thresholds['decay_threshold'] && $ga_data['views_prev'] >= $thresholds['min_previous_views'] ) {
			return true;
		}

		// Check for SEO flag
		if ( $gsc_data['impressions'] >= $thresholds['min_impressions'] && $gsc_data['ctr'] <= $thresholds['low_ctr'] ) {
			return true;
		}

		// Check for conversion flag
		if ( $ga_data['views'] >= $thresholds['high_views'] && $ga_data['form_fills'] <= $thresholds['low_conversions'] ) {
			return true;
		}

		return false;
	}

	/**
	 * Check if URL has stale PSI data
	 */
	private function has_stale_psi_data( $url ) {
		$cache_hours     = (int) get_option( 'cpanalyzer_pagespeed_cache_hours', 24 );
		$stale_threshold = time() - ( $cache_hours * 3600 );

		// Check existing content data in database
		global $wpdb;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Content caching functionality
		$content_data_json = $wpdb->get_var(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name is class property, safe
				"SELECT content_data FROM {$this->table_name} WHERE url = %s",
				$url
			)
		);

		if ( empty( $content_data_json ) ) {
			return true; // No data at all - needs analysis
		}

		$content_data = json_decode( $content_data_json, true );
		if ( ! isset( $content_data['psi'] ) ) {
			return true; // No PSI data - needs analysis
		}

		$psi_data = $content_data['psi'];

		// Check timestamps for available strategies
		$strategies = array( 'mobile', 'desktop' );
		foreach ( $strategies as $strategy ) {
			if ( isset( $psi_data[ $strategy ]['retrieved_at'] ) ) {
				$retrieved_time = strtotime( $psi_data[ $strategy ]['retrieved_at'] );
				if ( $retrieved_time > $stale_threshold ) {
					return false; // Found fresh data
				}
			}
		}

		return true; // All data is stale or missing
	}

	/**
	 * Build a PSI collector when enabled; otherwise return null.
	 *
	 * @param string $debug_prefix Prefix used in debug logs.
	 * @return CPAP_PageSpeed_Collector|null
	 */
	private function build_psi_collector( $debug_prefix ) {
		$psi_enabled = get_option( 'cpanalyzer_pagespeed_enable', 'no' );
		$psi_api_key = get_option( 'cpanalyzer_pagespeed_api_key', '' );

		$this->debug_log( "{$debug_prefix} PSI initialization - enabled: {$psi_enabled}, has_key: " . ( ! empty( $psi_api_key ) ? 'yes' : 'no' ) );

		if ( $psi_enabled !== 'yes' && $psi_enabled !== true ) {
			$this->debug_log( "{$debug_prefix} PSI is disabled - enabled setting: {$psi_enabled}" );
			return null;
		}

		if ( ! class_exists( 'CPAP_PageSpeed_Collector' ) ) {
			require_once CPANALYZER_PLUGIN_DIR . 'includes/class-cpa-pagespeed-collector.php';
		}

		$psi_collector = new CPAP_PageSpeed_Collector();
		if ( ! $psi_collector->is_enabled() ) {
			$this->debug_log( "{$debug_prefix} PSI enabled but not properly configured - skipping PSI analysis" );
			return null;
		}

		$this->debug_log( "{$debug_prefix} PSI collector initialized and ready" );
		return $psi_collector;
	}

	/**
	 * Scrape content and attempt PSI collection for a single URL.
	 *
	 * @param string                     $url         Relative uploaded URL path.
	 * @param array                      $ga_data     GA metrics for the URL.
	 * @param array                      $gsc_data    GSC metrics for the URL.
	 * @param CPAP_PageSpeed_Collector|null $psi_collector PSI collector instance or null.
	 * @param string                     $debug_prefix Prefix used in debug logs.
	 * @return array
	 */
	private function scrape_content_with_psi( $url, $ga_data, $gsc_data, $psi_collector, $debug_prefix ) {
		$content_scraper = new CPAP_Content_Scraper();
		$content_data    = $content_scraper->scrape_page_content( $url );

		if ( ! is_array( $content_data ) ) {
			$content_data = array();
		}

		unset( $content_data['psi_error'] );

		// Editorial inbound-link count for orphan detection. The map is
		// transient-cached, so this is a cheap array lookup per URL.
		if ( function_exists( 'cpanalyzer_get_inbound_link_map' ) ) {
			$inbound_map     = cpanalyzer_get_inbound_link_map();
			$normalized_path = cpanalyzer_normalize_path( $url );

			$content_data['inbound_internal_links'] = (int) ( $inbound_map[ $normalized_path ] ?? 0 );
			$content_data['is_orphan']              = ( 0 === $content_data['inbound_internal_links'] && '/' !== $normalized_path );
		}

		$this->debug_log( "{$debug_prefix} Checking PSI for URL: {$url} - collector exists: " . ( $psi_collector ? 'yes' : 'no' ) );

		if ( ! $psi_collector || ! $this->should_analyze_psi( $url, $ga_data, $gsc_data ) ) {
			return $content_data;
		}

		$this->debug_log( "{$debug_prefix} Running PSI analysis for URL: {$url}" );
		$full_url = home_url( $url );
		$this->debug_log( "{$debug_prefix} PSI API call for: {$full_url}" );
		$psi_data = $psi_collector->collect_pagespeed_data( $full_url );

		if ( $psi_data && ! isset( $psi_data['error'] ) ) {
			$content_data['psi'] = $psi_data;
			unset( $content_data['psi_error'] );

			$score = 'unknown';
			if ( isset( $psi_data['primary_strategy'] ) ) {
				$primary = $psi_data['primary_strategy'];
				$score   = $psi_data[ $primary ]['score'] ?? 'unknown';
			}

			$this->log_event(
				'psi_success',
				'PSI data collected for URL',
				array(
					'url'   => $url,
					'score' => $score,
				)
			);

			return $content_data;
		}

		unset( $content_data['psi'] );
		$error_msg = isset( $psi_data['error'] ) ? $psi_data['error'] : 'Unknown PSI error';
		$content_data['psi_error'] = sanitize_text_field( $error_msg );

		$this->log_event(
			'psi_error',
			'PSI analysis failed',
			array(
				'url'   => $url,
				'error' => $error_msg,
			)
		);

		return $content_data;
	}

	// get_service_account_file_path() removed — plugin now uses OAuth 2.0 (CPAP_Google_OAuth).

	/**
	 * Debug logging helper that respects WP_DEBUG_LOG setting
	 *
	 * @param string $message The message to log
	 */
	private function debug_log( $message ) {
		if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( $message );
		}
	}
}
