<?php

namespace SerpIQ\Gsc;

use SerpIQ\Helpers\Options;

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

/**
 * Historical backfill for Google Search Console data.
 *
 * Stores progress in serpiq_backfill_state and processes the range in small
 * date blocks, triggered by a cron event (serpiq_backfill_step).
 */
final class Backfill {
	private const OPTION_KEY = 'backfill_state';
	private const DEFAULT_BATCH_DAYS = 7;
	private const STUCK_SECONDS = 30 * MINUTE_IN_SECONDS;
	private const RECENT_SECONDS = 6 * HOUR_IN_SECONDS;
	private const AUTO_TUNE_MAX_BATCHES = 6;
	private const NEXT_RUN_GRACE_SECONDS = 30;
	private const SAFE_MODE_ERROR_THRESHOLD = 2;
	private const SAFE_MODE_WINDOW_SECONDS = 6 * HOUR_IN_SECONDS;

	private static ?Backfill $instance = null;

	public static function instance(): Backfill {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Start a new backfill for the given number of days.
	 *
	 * @param int $range_days Number of days to import (e.g. 30, 90, 180, 365, 485).
	 * @return array|\WP_Error
	 */
	public function start( int $range_days ) {
		$state = $this->get_state();
		if ( 'running' === $state['status'] ) {
			return new \WP_Error( 'serpiq_backfill_running', __( 'A historical import is already running.', 'seotune-search-insights' ) );
		}

		$range_days = max( 1, $range_days );

		$end_ts = strtotime( 'yesterday 00:00:00 UTC' );
		if ( ! $end_ts ) {
			$end_ts = time() - DAY_IN_SECONDS;
		}
		$end_date = gmdate( 'Y-m-d', $end_ts );
		$start_ts = $end_ts - ( $range_days - 1 ) * DAY_IN_SECONDS;
		$start_date = gmdate( 'Y-m-d', $start_ts );

		$total_days = (int) floor( ( $end_ts - $start_ts ) / DAY_IN_SECONDS ) + 1;

		$new_state = array(
			'status'               => 'running',
			'range_days'           => $range_days,
			'target_start_date'    => $start_date,
			'target_end_date'      => $end_date,
			'total_days'           => $total_days,
			'imported_days'        => 0,
			'current_date'         => null,
			'last_batch_start'     => null,
			'last_batch_end'       => null,
			'last_batch_rows'      => 0,
			'last_batch_duration'  => 0,
			'created_at'           => gmdate( 'c' ),
			'last_step_at'         => gmdate( 'c' ),
			'completed_at'         => null,
			'error_message'        => null,
			'auto_tuning'          => array(
				'enabled'        => false,
				'started_at'     => null,
				'batches_seen'   => 0,
				'last_adjust_at' => null,
				'mode'           => null, // safe|adaptive|disabled
				'current'        => array(
					'batch_days'    => null,
					'db_batch_size' => null,
					'delay_seconds' => null,
				),
			),
		);

		// Milestone 1.2: auto-tuning on "unknown" hosting, only on fresh installs
		// where we have no sync logs yet.
		//
		// Safe defaults:
		// - Days per block: 3–5
		// - Rows per DB batch: 500–700
		// - Delay: 60s
		//
		// If early batches are fast and error-free, we progressively increase towards
		// the standard defaults (batch_days=7, db_batch_size=1000, delay=10).
		try {
			$has_logs = $this->has_any_sync_logs();
			$health   = isset( $state['last_health_check'] ) && is_array( $state['last_health_check'] ) ? $state['last_health_check'] : null;
			$env_flag = is_array( $health ) && isset( $health['environment_flag'] ) ? (string) $health['environment_flag'] : null;

			$hosting_unknown = ( null === $health || empty( $health ) ) && ( null === $env_flag || '' === $env_flag );
			if ( ! $has_logs && $hosting_unknown ) {
				// Start in safe mode.
				$safe_batch_days = 4;   // 3–5
				$safe_db_batch   = 600; // 500–700
				$safe_delay      = 60;

				update_option( Options::backfill_batch_days_key(), $safe_batch_days, false );
				update_option( Options::db_batch_size_key(), $safe_db_batch, false );
				update_option( Options::backfill_delay_seconds_key(), $safe_delay, false );

				$new_state['auto_tuning'] = array(
					'enabled'        => true,
					'started_at'     => gmdate( 'c' ),
					'batches_seen'   => 0,
					'last_adjust_at' => gmdate( 'c' ),
					'mode'           => 'safe',
					'current'        => array(
						'batch_days'    => $safe_batch_days,
						'db_batch_size' => $safe_db_batch,
						'delay_seconds' => $safe_delay,
					),
				);
			}
		} catch ( \Throwable $e ) {
			// Best-effort only; tuning must never block the import start.
		}

		update_option( Options::backfill_state_key(), $new_state, false );

		// Schedule first batch (clear any stale events to avoid duplicates).
		wp_clear_scheduled_hook( 'serpiq_backfill_step' );
		wp_schedule_single_event( time() + $this->get_delay_seconds(), 'serpiq_backfill_step' );

		return $new_state;
	}

	/**
	 * Run one batch (3 days) of the backfill. Safe to call from cron.
	 */
	public function run_next_batch(): void {
		$state = $this->get_state();
		if ( 'running' !== $state['status'] ) {
			return;
		}

		// Self-healing (Milestone 2.1): if the next scheduled run is in the past,
		// clear and reschedule immediately before doing work, to avoid "lost" events.
		$this->maybe_reschedule_if_next_run_in_past();

		$target_start = $state['target_start_date'];
		$target_end   = $state['target_end_date'];
		if ( ! $target_start || ! $target_end ) {
			$state['status'] = 'error';
			$state['error_message'] = 'Invalid backfill target dates.';
			update_option( Options::backfill_state_key(), $state, false );
			return;
		}

		// Determine next block.
		$next_start_ts = $state['current_date']
			? strtotime( $state['current_date'] . ' +1 day' )
			: strtotime( $target_start );

		$target_end_ts = strtotime( $target_end );
		if ( false === $next_start_ts || false === $target_end_ts ) {
			$state['status'] = 'error';
			$state['error_message'] = 'Unable to compute next backfill batch.';
			update_option( Options::backfill_state_key(), $state, false );
			return;
		}

		if ( $next_start_ts > $target_end_ts ) {
			$state['status']      = 'completed';
			$state['completed_at'] = gmdate( 'c' );
			update_option( Options::backfill_state_key(), $state, false );
			return;
		}

		$batch_days   = $this->get_batch_days();
		$batch_end_ts = min( $target_end_ts, $next_start_ts + ( $batch_days - 1 ) * DAY_IN_SECONDS );
		$batch_start  = gmdate( 'Y-m-d', $next_start_ts );
		$batch_end    = gmdate( 'Y-m-d', $batch_end_ts );

		$time_start = microtime( true );
		$result = Sync::instance()->run_range( $batch_start, $batch_end, 'backfill_batch' );
		$duration_ms = (int) round( ( microtime( true ) - $time_start ) * 1000 );

		if ( is_wp_error( $result ) ) {
			$state['status']        = 'error';
			$state['error_message'] = $result->get_error_message();
			$state['last_batch_start'] = $batch_start;
			$state['last_batch_end']   = $batch_end;
			$state['last_batch_rows']  = 0;
			$state['last_batch_duration'] = $duration_ms;
			$state['last_step_at']      = gmdate( 'c' );
			update_option( Options::backfill_state_key(), $state, false );
			return;
		}

		$days_in_batch = (int) floor( ( $batch_end_ts - $next_start_ts ) / DAY_IN_SECONDS ) + 1;

		$state['current_date']        = $batch_end;
		$state['imported_days']       = min( $state['total_days'], $state['imported_days'] + $days_in_batch );
		$state['last_batch_start']    = $batch_start;
		$state['last_batch_end']      = $batch_end;
		$state['last_batch_rows']     = isset( $result['rowsImported'] ) ? (int) $result['rowsImported'] : 0;
		$state['last_batch_duration'] = $duration_ms;
		$state['error_message']       = null;
		$state['last_step_at']        = gmdate( 'c' );

		if ( $batch_end_ts >= $target_end_ts || $state['imported_days'] >= $state['total_days'] ) {
			$state['status']       = 'completed';
			$state['completed_at'] = gmdate( 'c' );
		}

		// Apply adaptive auto-tuning (Milestone 1.2) after successful batches.
		$state = $this->maybe_adapt_tuning_after_success( $state, (int) $state['last_batch_rows'], $duration_ms );
		// Safe mode automatic rollback (Milestone 2.3): if the host stabilizes,
		// allow returning to standard settings.
		$state = $this->maybe_disable_safe_mode_if_stable( $state, (int) $state['last_batch_rows'], $duration_ms );

		update_option( Options::backfill_state_key(), $state, false );

		// If still running, always schedule the next step explicitly to avoid relying
		// on potentially stale cron events.
		if ( 'running' === $state['status'] ) {
			wp_clear_scheduled_hook( 'serpiq_backfill_step' );
			wp_schedule_single_event( time() + $this->get_delay_seconds(), 'serpiq_backfill_step' );
			// And double-check the scheduled time is not in the past (edge cases).
			$this->maybe_reschedule_if_next_run_in_past();
		}
	}

	/**
	 * Get the delay in seconds between scheduling the next backfill batch.
	 * Allowed values: 10, 30, 60. Default 60.
	 */
	public function get_delay_seconds(): int {
		$val = (int) get_option( Options::backfill_delay_seconds_key(), 10 );
		$allowed = array( 10, 30, 60 );
		return in_array( $val, $allowed, true ) ? $val : 10;
	}

	/**
	 * Whether any sync logs exist yet.
	 */
	private function has_any_sync_logs(): bool {
		global $wpdb;
		$table = $wpdb->prefix . 'serpiq_sync_log';
		$has_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		if ( ! $has_table ) {
			return false;
		}
		$expected_table = $wpdb->prefix . 'serpiq_sync_log';
		if ( $table !== $expected_table || ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
			return false;
		}
		$count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(1) FROM %i LIMIT 1', $table ) );
		return $count > 0;
	}

	/**
	 * Adaptive tuning for early batches:
	 * - If batches are fast and error-free, increase towards standard values.
	 * - Keep changes bounded and stop after a few batches.
	 *
	 * @param array<string,mixed> $state
	 * @param int $rows_imported
	 * @param int $duration_ms
	 * @return array<string,mixed>
	 */
	private function maybe_adapt_tuning_after_success( array $state, int $rows_imported, int $duration_ms ): array {
		$tuning = isset( $state['auto_tuning'] ) && is_array( $state['auto_tuning'] ) ? $state['auto_tuning'] : null;
		$enabled = is_array( $tuning ) && ! empty( $tuning['enabled'] );
		if ( ! $enabled ) {
			return $state;
		}

		$batches_seen = isset( $tuning['batches_seen'] ) ? (int) $tuning['batches_seen'] : 0;
		$batches_seen++;

		// Stop adapting after a few batches to avoid oscillations.
		if ( $batches_seen > self::AUTO_TUNE_MAX_BATCHES ) {
			$tuning['batches_seen'] = $batches_seen;
			$tuning['mode']         = 'disabled';
			$tuning['enabled']      = false;
			$tuning['last_adjust_at'] = gmdate( 'c' );
			$state['auto_tuning']   = $tuning;
			return $state;
		}

		$cur_batch_days = (int) get_option( Options::backfill_batch_days_key(), self::DEFAULT_BATCH_DAYS );
		$cur_db_batch   = (int) get_option( Options::db_batch_size_key(), 1000 );
		$cur_delay      = (int) get_option( Options::backfill_delay_seconds_key(), 10 );

		// Heuristic: fast if < 8s. If the host is slower, keep safe mode longer.
		$is_fast = $duration_ms > 0 && $duration_ms <= 8000;
		// Another heuristic: "not tiny" import suggests DB writes are working well.
		$has_work = $rows_imported >= 500;

		$new_batch_days = $cur_batch_days;
		$new_db_batch   = $cur_db_batch;
		$new_delay      = $cur_delay;

		if ( $is_fast && $has_work ) {
			// Progressively ramp up.
			$new_batch_days = min( 7, max( 3, $cur_batch_days + 1 ) );
			$new_db_batch   = min( 1000, max( 500, $cur_db_batch + 200 ) );
			$new_delay      = ( $cur_delay > 30 ) ? 30 : 10;
			$tuning['mode'] = 'adaptive';
		} elseif ( $duration_ms >= 20000 ) {
			// Too slow: stay conservative.
			$new_batch_days = min( $cur_batch_days, 4 );
			$new_db_batch   = min( $cur_db_batch, 600 );
			$new_delay      = 60;
			$tuning['mode'] = 'safe';
		}

		// Apply option updates only if they actually changed.
		if ( $new_batch_days !== $cur_batch_days ) {
			update_option( Options::backfill_batch_days_key(), $new_batch_days, false );
		}
		if ( $new_db_batch !== $cur_db_batch ) {
			update_option( Options::db_batch_size_key(), $new_db_batch, false );
		}
		if ( $new_delay !== $cur_delay ) {
			update_option( Options::backfill_delay_seconds_key(), $new_delay, false );
		}

		$tuning['batches_seen']   = $batches_seen;
		$tuning['last_adjust_at'] = gmdate( 'c' );
		$tuning['current']        = array(
			'batch_days'    => $new_batch_days,
			'db_batch_size' => $new_db_batch,
			'delay_seconds' => $new_delay,
		);
		$state['auto_tuning'] = $tuning;

		return $state;
	}

	/**
	 * Stop and reset the current backfill state (useful if it got stuck).
	 * Clears the scheduled cron event and sets status back to idle.
	 */
	public function stop_and_reset(): void {
		wp_clear_scheduled_hook( 'serpiq_backfill_step' );
		delete_option( Options::backfill_state_key() );
	}

	/**
	 * Force scheduling the next backfill step ASAP (support tool).
	 *
	 * @return array<string,mixed>|\WP_Error
	 */
	public function force_next_step() {
		$state = $this->get_state();
		$status = isset( $state['status'] ) ? (string) $state['status'] : 'idle';
		if ( 'running' !== $status ) {
			return new \WP_Error( 'serpiq_backfill_not_running', __( 'Backfill is not running; cannot force next step.', 'seotune-search-insights' ) );
		}

		wp_clear_scheduled_hook( 'serpiq_backfill_step' );
		wp_schedule_single_event( time() + 5, 'serpiq_backfill_step' );

		$health = isset( $state['last_health_check'] ) && is_array( $state['last_health_check'] )
			? $state['last_health_check']
			: array();
		$health['last_force_next_step_at'] = gmdate( 'c' );
		$state['last_health_check'] = $health;
		update_option( Options::backfill_state_key(), $state, false );

		return $state;
	}

	/**
	 * Determine if a running backfill looks stuck (no progress for a while).
	 */
	public function is_stuck( array $state = null ): bool {
		$state = is_array( $state ) ? $state : $this->get_state();
		if ( 'running' !== ( $state['status'] ?? '' ) ) {
			return false;
		}
		$last = (string) ( $state['last_step_at'] ?? '' );
		$last_ts = $last ? strtotime( $last ) : false;
		if ( false === $last_ts ) {
			return false;
		}
		return ( time() - $last_ts ) > self::STUCK_SECONDS;
	}

	/**
	 * Get current backfill state.
	 *
	 * @return array<string,mixed>
	 */
	public function get_state(): array {
		$state = get_option( Options::backfill_state_key(), array() );
		if ( ! is_array( $state ) ) {
			$state = array();
		}

		$defaults = array(
			'status'              => 'idle',
			'range_days'          => 0,
			'target_start_date'   => null,
			'target_end_date'     => null,
			'total_days'          => 0,
			'imported_days'       => 0,
			'current_date'        => null,
			'last_batch_start'    => null,
			'last_batch_end'      => null,
			'last_batch_rows'     => 0,
			'last_batch_duration' => 0,
			'created_at'          => null,
			'last_step_at'        => null,
			'completed_at'        => null,
			'error_message'       => null,
			'last_health_check'   => null,
		);

		return array_merge( $defaults, $state );
	}

	/**
	 * Get the current batch size (in days) for backfill batches.
	 * Falls back to DEFAULT_BATCH_DAYS if no custom option is set or invalid.
	 */
	public function get_batch_days(): int {
		$val = (int) get_option( Options::backfill_batch_days_key(), self::DEFAULT_BATCH_DAYS );
		if ( $val <= 0 ) {
			return self::DEFAULT_BATCH_DAYS;
		}
		// Clamp to a reasonable range to avoid huge batches.
		if ( $val < 1 ) {
			$val = 1;
		} elseif ( $val > 30 ) {
			$val = 30;
		}
		return $val;
	}

	/**
	 * Compute actual data coverage for the current backfill target range based on
	 * rows present in the serpiq_pages table.
	 *
	 * @param array<string,mixed> $state Current backfill state.
	 * @return array{earliest:?string,latest:?string,coveredDays:int,totalDays:int,coversStart:bool,coversEnd:bool}
	 */
	private function get_import_coverage_for_state( array $state ): array {
		global $wpdb;

		$target_start = isset( $state['target_start_date'] ) ? (string) $state['target_start_date'] : '';
		$target_end   = isset( $state['target_end_date'] ) ? (string) $state['target_end_date'] : '';

		$earliest = null;
		$latest   = null;
		$covered  = 0;
		$total    = 0;

		if ( '' === $target_start || '' === $target_end ) {
			return array(
				'earliest'    => null,
				'latest'      => null,
				'coveredDays' => 0,
				'totalDays'   => 0,
				'coversStart' => false,
				'coversEnd'   => false,
			);
		}

		$table = $wpdb->prefix . 'serpiq_pages';

		$start_ts = strtotime( $target_start );
		$end_ts   = strtotime( $target_end );
		if ( false === $start_ts || false === $end_ts || $start_ts > $end_ts ) {
			return array(
				'earliest'    => null,
				'latest'      => null,
				'coveredDays' => 0,
				'totalDays'   => 0,
				'coversStart' => false,
				'coversEnd'   => false,
			);
		}

		$total = (int) floor( ( $end_ts - $start_ts ) / DAY_IN_SECONDS ) + 1;

		$earliest = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT MIN(date) FROM %i WHERE date BETWEEN %s AND %s',
				$table,
				$target_start,
				$target_end
			)
		);
		$latest = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT MAX(date) FROM %i WHERE date BETWEEN %s AND %s',
				$table,
				$target_start,
				$target_end
			)
		);

		$earliest = $earliest ? (string) $earliest : null;
		$latest   = $latest ? (string) $latest : null;

		// Count real coverage by distinct days present in the DB for this target range.
		$covered = (int) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(DISTINCT date) FROM %i WHERE date BETWEEN %s AND %s',
				$table,
				$target_start,
				$target_end
			)
		);

		$covers_start = ( $earliest && $earliest <= $target_start );
		$covers_end   = ( $latest && $latest >= $target_end );

		return array(
			'earliest'    => $earliest,
			'latest'      => $latest,
			'coveredDays' => $covered,
			'totalDays'   => $total,
			'coversStart' => (bool) $covers_start,
			'coversEnd'   => (bool) $covers_end,
		);
	}

	/**
	 * Public helper: get data coverage for the current backfill state.
	 *
	 * @return array{earliest:?string,latest:?string,coveredDays:int,totalDays:int}
	 */
	public function get_current_import_coverage(): array {
		$state = $this->get_state();
		return $this->get_import_coverage_for_state( $state );
	}

	/**
	 * Resume an interrupted backfill from the actual data coverage found in the
	 * tables, instead of starting over from scratch.
	 *
	 * @return array<string,mixed>|\WP_Error Updated backfill state or error.
	 */
	public function resume_from_coverage() {
		$state = $this->get_state();
		$status = isset( $state['status'] ) ? (string) $state['status'] : 'idle';

		if ( ! in_array( $status, array( 'running', 'error' ), true ) ) {
			return new \WP_Error( 'serpiq_backfill_not_resumable', __( 'There is no running or failed historical import to resume.', 'seotune-search-insights' ) );
		}

		$coverage = $this->get_import_coverage_for_state( $state );
		if ( $coverage['coveredDays'] <= 0 ) {
			return new \WP_Error( 'serpiq_backfill_no_coverage', __( 'No imported data was found in the target range; nothing to resume.', 'seotune-search-insights' ) );
		}

		// Align imported_days/current_date with actual data coverage.
		//
		// IMPORTANT: Only advance the cursor if the data covers the target range from the start.
		// If we only have a recent slice (e.g. last 14 days) for a larger target range,
		// we must NOT jump current_date to the end, otherwise the backfill would appear complete.
		$total_days = (int) ( $state['total_days'] ?? $coverage['totalDays'] );
		$state['imported_days'] = min( (int) $coverage['coveredDays'], $total_days );
		$state['current_date']  = ! empty( $coverage['coversStart'] ) ? $coverage['latest'] : null;
		$state['status']        = 'running';
		$state['error_message'] = null;
		$state['last_step_at']  = gmdate( 'c' );

		if ( ! empty( $coverage['coversStart'] ) && ! empty( $coverage['coversEnd'] ) && $coverage['coveredDays'] >= $coverage['totalDays'] ) {
			$state['status']       = 'completed';
			$state['completed_at'] = gmdate( 'c' );
		}

		// Track that a manual resume was performed (for diagnostics).
		$health = isset( $state['last_health_check'] ) && is_array( $state['last_health_check'] )
			? $state['last_health_check']
			: array();
		$health['last_resume_at'] = gmdate( 'c' );
		$state['last_health_check'] = $health;

		update_option( Options::backfill_state_key(), $state, false );

		if ( 'running' === $state['status'] ) {
			wp_clear_scheduled_hook( 'serpiq_backfill_step' );
			wp_schedule_single_event( time() + $this->get_delay_seconds(), 'serpiq_backfill_step' );
		}

		return $state;
	}

	/**
	 * Request-level memo for tail reads on serpiq_sync_log (diagnostics UI + health check).
	 * Avoids repeating identical probes in the same request. Not used on import batch hot paths.
	 *
	 * @return array{table_exists:bool,last_any:?array<string,mixed>,last_backfill:?array<string,mixed>}
	 */
	private function get_sync_log_tail_cached(): array {
		static $cache = null;
		if ( null !== $cache ) {
			return $cache;
		}

		global $wpdb;

		$table = $wpdb->prefix . 'serpiq_sync_log';
		$cache = array(
			'table_exists'  => false,
			'last_any'      => null,
			'last_backfill' => null,
		);

		if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
			return $cache;
		}

		$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		if ( ! $exists ) {
			return $cache;
		}

		$cache['table_exists'] = true;
		$cache['last_any']     = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT created_at, job_type, status, error_message FROM %i ORDER BY created_at DESC LIMIT 1',
				$table
			),
			ARRAY_A
		);
		$cache['last_backfill'] = $wpdb->get_row(
			$wpdb->prepare(
				'SELECT created_at FROM %i WHERE job_type = %s ORDER BY created_at DESC LIMIT 1',
				$table,
				'backfill_batch'
			),
			ARRAY_A
		);

		return $cache;
	}

	/**
	 * Run a lightweight health check and persist the result into backfill_state.
	 *
	 * This is intended to help diagnose why an import is stuck or failing
	 * without requiring direct server access.
	 *
	 * @return array<string,mixed> The computed health check data.
	 */
	public function run_health_check(): array {
		global $wpdb;

		$state = $this->get_state();

		$now_iso = gmdate( 'c' );

		// Self-healing (Milestone 2.1): if backfill is running but the next run is
		// scheduled in the past, reschedule immediately so it doesn't stall.
		if ( 'running' === (string) ( $state['status'] ?? '' ) ) {
			$this->maybe_reschedule_if_next_run_in_past();
		}

		// Cron information.
		$next_backfill_ts = wp_next_scheduled( 'serpiq_backfill_step' );
		$cron_info = array(
			'backfill_step_scheduled' => (bool) $next_backfill_ts,
			'next_run'                => $next_backfill_ts ? gmdate( 'c', (int) $next_backfill_ts ) : null,
		);

		// Log information (latest overall + latest backfill_batch).
		$log_info = array(
			'has_any_logs'          => false,
			'last_log_created_at'   => null,
			'last_backfill_created' => null,
			'last_error_message'    => null,
		);

		$sync_probe = $this->get_sync_log_tail_cached();
		if ( $sync_probe['table_exists'] ) {
			$last_any = $sync_probe['last_any'];
			if ( $last_any ) {
				$log_info['has_any_logs']        = true;
				$log_info['last_log_created_at'] = (string) ( $last_any['created_at'] ?? '' );
				if ( ! empty( $last_any['error_message'] ) && 'error' === ( $last_any['status'] ?? '' ) ) {
					$log_info['last_error_message'] = (string) $last_any['error_message'];
				}
			}

			$last_backfill = $sync_probe['last_backfill'];
			if ( $last_backfill && ! empty( $last_backfill['created_at'] ) ) {
				$log_info['last_backfill_created'] = (string) $last_backfill['created_at'];
			}
		}

		// Basic interpretation of last error.
		$gsc_error  = null;
		$php_db_err = null;
		$err        = (string) ( $state['error_message'] ?? '' );
		if ( '' === $err && ! empty( $log_info['last_error_message'] ) ) {
			$err = (string) $log_info['last_error_message'];
		}
		if ( $err ) {
			$lower = strtolower( $err );
			if (
				strpos( $lower, 'quota' ) !== false ||
				strpos( $lower, 'rate limit' ) !== false ||
				strpos( $lower, 'user ratelimit' ) !== false
			) {
				$gsc_error = 'quota_or_ratelimit';
			} elseif ( strpos( $lower, 'invalid_grant' ) !== false ) {
				$gsc_error = 'invalid_grant';
			}

			if (
				strpos( $lower, 'max_execution_time' ) !== false ||
				strpos( $lower, 'maximum execution time' ) !== false
			) {
				$php_db_err = 'timeout';
			} elseif (
				strpos( $lower, 'allowed memory size' ) !== false ||
				strpos( $lower, 'out of memory' ) !== false
			) {
				$php_db_err = 'memory';
			} elseif (
				strpos( $lower, 'mysql server has gone away' ) !== false ||
				strpos( $lower, 'deadlock found' ) !== false
			) {
				$php_db_err = 'database';
			}
		}

		// Very lightweight data presence check.
		$has_data_in_tables = false;
		$pages_table        = $wpdb->prefix . 'serpiq_pages';
		$expected_pages_table = $wpdb->prefix . 'serpiq_pages';
		if ( $pages_table === $expected_pages_table && preg_match( '/^[a-zA-Z0-9_]+$/', $pages_table ) && $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $pages_table ) ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(1) FROM %i LIMIT 1', $pages_table ) );
			$has_data_in_tables = $count > 0;
		}

		$environment_flag = null;
		if ( $php_db_err ) {
			$environment_flag = 'restricted_hosting';
		}

		// Milestone 2.3: if we detect repeated PHP/DB limits, enable safe import mode.
		$state = $this->maybe_enable_safe_mode_if_needed( $state, $php_db_err );

		$health = array(
			'checked_at'         => $now_iso,
			'cron'               => $cron_info,
			'logs'               => $log_info,
			'gsc_error'          => $gsc_error,
			'php_db_error'       => $php_db_err,
			'has_data_in_tables' => $has_data_in_tables,
			'environment_flag'   => $environment_flag,
		);

		$state['last_health_check'] = $health;
		update_option( Options::backfill_state_key(), $state, false );

		return $health;
	}

	/**
	 * Lightweight diagnostics for cron / backfill status to drive the admin UI.
	 *
	 * @return array<string,mixed> {
	 *   @type string $level        One of ok|warning|error.
	 *   @type string $code         Machine-readable code for the situation.
	 *   @type string $message      Human-readable summary.
	 *   @type string $hint         Generic suggestion for the site owner.
	 *   @type string|null $last_log_at         ISO8601 timestamp of the last log entry (any job).
	 *   @type string|null $last_backfill_log_at ISO8601 timestamp of the last backfill_batch log.
	 * }
	 */
	public function get_cron_diagnostics(): array {
		$state = $this->get_state();

		// Self-healing (Milestone 2.1): if an event is scheduled in the past while running,
		// fix it so the UI doesn't show "next run" in the past for long.
		if ( 'running' === (string) ( $state['status'] ?? '' ) ) {
			$this->maybe_reschedule_if_next_run_in_past();
		}

		// M5: share one probe with run_health_check() in the same request when diagnostics load both.
		$probe             = $this->get_sync_log_tail_cached();
		$last_log          = $probe['last_any'];
		$last_backfill_log = $probe['last_backfill'];

		$now = time();
		$recent_threshold = $now - self::RECENT_SECONDS;

		$last_log_ts = null;
		if ( $last_log && ! empty( $last_log['created_at'] ) ) {
			$ts = strtotime( $last_log['created_at'] );
			$last_log_ts = false !== $ts ? $ts : null;
		}

		$last_backfill_ts = null;
		if ( $last_backfill_log && ! empty( $last_backfill_log['created_at'] ) ) {
			$ts = strtotime( $last_backfill_log['created_at'] );
			$last_backfill_ts = false !== $ts ? $ts : null;
		}

		$last_log_iso = $last_log_ts ? gmdate( 'c', $last_log_ts ) : null;
		$last_backfill_iso = $last_backfill_ts ? gmdate( 'c', $last_backfill_ts ) : null;

		$level   = 'ok';
		$code    = 'cron_ok';
		$message = __( 'Cron appears to be running normally.', 'seotune-search-insights' );
		$hint    = __( 'If imports look slow, you can lower the historical import batch size in Settings.', 'seotune-search-insights' );

		$status = (string) ( $state['status'] ?? 'idle' );

		// Backfill is running: look for stuck or missing batches.
		if ( 'running' === $status ) {
			if ( $this->is_stuck( $state ) ) {
				// When a running import looks stuck, run a health check so the UI and support
				// can see why without additional server access.
				$this->run_health_check();
				$level   = 'error';
				$code    = 'backfill_stuck';
				$message = __( 'Historical import seems stuck (no progress for a while).', 'seotune-search-insights' );
				$hint    = __( 'Check that WordPress cron is enabled or configure a server cron to call wp-cron.php regularly, then consider using Stop / Reset and starting the import again.', 'seotune-search-insights' );
			} else {
				// If we have a created_at in state but no backfill_batch logs after that, it likely never started.
				$created_at = isset( $state['created_at'] ) ? (string) $state['created_at'] : '';
				$created_ts = $created_at ? strtotime( $created_at ) : false;

				if ( $created_ts && ( ! $last_backfill_ts || $last_backfill_ts < $created_ts ) ) {
					$level   = 'warning';
					$code    = 'backfill_no_batches';
					$message = __( 'Historical import is marked as running, but no backfill batches have completed yet.', 'seotune-search-insights' );
					$hint    = __( 'This often means cron events are not firing. Ensure that wp-cron is enabled or that a real cron job triggers it, then wait a few minutes and refresh.', 'seotune-search-insights' );
				} elseif ( $last_backfill_ts && $last_backfill_ts < $recent_threshold ) {
					$level   = 'warning';
					$code    = 'backfill_no_recent_batches';
					$message = __( 'Historical import is running, but no recent batches have been logged.', 'seotune-search-insights' );
					$hint    = __( 'Cron may be running infrequently on this site. Verify your cron configuration if progress stays at 0% for several hours.', 'seotune-search-insights' );
				} else {
					$level   = 'ok';
					$code    = 'backfill_running';
					$message = __( 'Historical import is running and recent batches have been logged.', 'seotune-search-insights' );
					$hint    = __( 'You can keep this page open or refresh occasionally to see progress. Large sites may take time to complete.', 'seotune-search-insights' );
				}
			}
		} else {
			// No backfill currently running.
			if ( ! $last_log_ts ) {
				// Fresh install or no diagnostics yet: stay neutral.
				$level   = 'ok';
				$code    = 'no_logs';
				$message = __( 'No diagnostics have been recorded yet. They will appear after the next import or cron run.', 'seotune-search-insights' );
				$hint    = '';
			} elseif ( $last_log_ts < $recent_threshold ) {
				$level   = 'warning';
				$code    = 'no_recent_logs';
				$message = __( 'No plugin cron jobs have run in the last few hours.', 'seotune-search-insights' );
				$hint    = __( 'This often indicates that WordPress cron is disabled or not being triggered. Check DISABLE_WP_CRON in wp-config.php or configure a server cron that calls wp-cron.php.', 'seotune-search-insights' );
			}
		}

		return array(
			'level'               => $level,
			'code'                => $code,
			'message'             => $message,
			'hint'                => $hint,
			'last_log_at'         => $last_log_iso,
			'last_backfill_log_at'=> $last_backfill_iso,
		);
	}

	/**
	 * Milestone 2.1: if the next scheduled backfill step is in the past,
	 * clear and reschedule immediately to avoid stalled imports.
	 */
	private function maybe_reschedule_if_next_run_in_past(): void {
		$next = wp_next_scheduled( 'serpiq_backfill_step' );
		if ( ! $next ) {
			return;
		}
		$now = time();
		if ( (int) $next < ( $now - self::NEXT_RUN_GRACE_SECONDS ) ) {
			wp_clear_scheduled_hook( 'serpiq_backfill_step' );
			wp_schedule_single_event( $now + 10, 'serpiq_backfill_step' );
		}
	}

	/**
	 * Enable safe import mode when repeated php/db limit errors are detected.
	 *
	 * @param array<string,mixed> $state
	 * @param string|null $php_db_err One of timeout|memory|database|null.
	 * @return array<string,mixed>
	 */
	private function maybe_enable_safe_mode_if_needed( array $state, ?string $php_db_err ): array {
		// Only react to php/db limit errors.
		if ( ! $php_db_err ) {
			return $state;
		}

		// Count recent backfill_batch errors matching php/db patterns.
		$recent = $this->count_recent_php_db_errors_in_logs( self::SAFE_MODE_WINDOW_SECONDS );
		if ( $recent < self::SAFE_MODE_ERROR_THRESHOLD ) {
			return $state;
		}

		$safe = isset( $state['safe_mode'] ) && is_array( $state['safe_mode'] ) ? $state['safe_mode'] : array();
		if ( ! empty( $safe['enabled'] ) ) {
			// Already enabled.
			return $state;
		}

		$prev = array(
			'batch_days'    => (int) get_option( Options::backfill_batch_days_key(), self::DEFAULT_BATCH_DAYS ),
			'db_batch_size' => (int) get_option( Options::db_batch_size_key(), 1000 ),
			'delay_seconds' => (int) get_option( Options::backfill_delay_seconds_key(), 10 ),
		);

		// Conservative values.
		$new = array(
			'batch_days'    => 3,
			'db_batch_size' => 500,
			'delay_seconds' => 60,
		);

		update_option( Options::backfill_batch_days_key(), $new['batch_days'], false );
		update_option( Options::db_batch_size_key(), $new['db_batch_size'], false );
		update_option( Options::backfill_delay_seconds_key(), $new['delay_seconds'], false );

		$state['safe_mode'] = array(
			'enabled'     => true,
			'reason'      => 'php_db_limits',
			'detected'    => $php_db_err,
			'errors_seen' => $recent,
			'applied_at'  => gmdate( 'c' ),
			'prev'        => $prev,
			'current'     => $new,
		);

		return $state;
	}

	/**
	 * Disable safe mode when things look stable again.
	 *
	 * Heuristic: if safe mode is enabled and we see no php/db errors recently,
	 * and the latest successful batch was reasonably fast, restore previous settings.
	 *
	 * @param array<string,mixed> $state
	 */
	private function maybe_disable_safe_mode_if_stable( array $state, int $rows_imported, int $duration_ms ): array {
		$safe = isset( $state['safe_mode'] ) && is_array( $state['safe_mode'] ) ? $state['safe_mode'] : null;
		if ( ! is_array( $safe ) || empty( $safe['enabled'] ) ) {
			return $state;
		}

		$recent = $this->count_recent_php_db_errors_in_logs( self::SAFE_MODE_WINDOW_SECONDS );
		$is_fast = $duration_ms > 0 && $duration_ms <= 8000;
		$has_work = $rows_imported >= 500;
		if ( $recent > 0 || ! ( $is_fast && $has_work ) ) {
			return $state;
		}

		$prev = isset( $safe['prev'] ) && is_array( $safe['prev'] ) ? $safe['prev'] : array();
		if ( empty( $prev ) ) {
			return $state;
		}

		update_option( Options::backfill_batch_days_key(), (int) ( $prev['batch_days'] ?? self::DEFAULT_BATCH_DAYS ), false );
		update_option( Options::db_batch_size_key(), (int) ( $prev['db_batch_size'] ?? 1000 ), false );
		update_option( Options::backfill_delay_seconds_key(), (int) ( $prev['delay_seconds'] ?? 10 ), false );

		$safe['enabled']     = false;
		$safe['disabled_at'] = gmdate( 'c' );
		$state['safe_mode']  = $safe;

		return $state;
	}

	/**
	 * Count recent backfill_batch errors likely caused by PHP/DB limits.
	 */
	private function count_recent_php_db_errors_in_logs( int $window_seconds ): int {
		global $wpdb;
		$table = $wpdb->prefix . 'serpiq_sync_log';
		$has_table = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
		if ( ! $has_table ) {
			return 0;
		}
		$expected_table = $wpdb->prefix . 'serpiq_sync_log';
		if ( $table !== $expected_table || ! preg_match( '/^[a-zA-Z0-9_]+$/', $table ) ) {
			return 0;
		}

		$since = gmdate( 'Y-m-d H:i:s', time() - max( 60, $window_seconds ) );
		$rows = $wpdb->get_col(
			$wpdb->prepare(
				'SELECT error_message FROM %i WHERE job_type = %s AND status = %s AND created_at >= %s ORDER BY created_at DESC LIMIT 20',
				$table,
				'backfill_batch',
				'error',
				$since
			)
		);
		if ( ! is_array( $rows ) || empty( $rows ) ) {
			return 0;
		}

		$count = 0;
		foreach ( $rows as $msg ) {
			$lower = strtolower( (string) $msg );
			if (
				strpos( $lower, 'maximum execution time' ) !== false ||
				strpos( $lower, 'max_execution_time' ) !== false ||
				strpos( $lower, 'allowed memory size' ) !== false ||
				strpos( $lower, 'out of memory' ) !== false ||
				strpos( $lower, 'mysql server has gone away' ) !== false ||
				strpos( $lower, 'deadlock found' ) !== false
			) {
				$count++;
			}
		}

		return $count;
	}
}

