<?php
/**
 * Check orchestrator: runs the flight checks, stores snapshots, writes the
 * flight log, resolves cleared findings, and hands new ones to alerts.
 *
 * Runs are batched with a wall-clock budget so a slow host never times out a
 * cron tick: each batch processes a few pages and, when more remain, chains
 * the next batch as a single cron event.
 *
 * @package SEO_Flight_Recorder
 */

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

/**
 * Class SFRec_Scanner
 */
class SFRec_Scanner {

	const BATCH_SIZE   = 5;
	const TIME_BUDGET  = 40; // Seconds per batch invocation.
	const RUN_LIFETIME = 900; // Run bookkeeping transient TTL.

	/**
	 * Wire cron hooks.
	 */
	public static function init() {
		add_action( 'sfrec_scheduled_check', array( __CLASS__, 'cron_scheduled' ) );
		add_action( 'sfrec_post_update_check', array( __CLASS__, 'cron_post_update' ) );
		add_action( 'sfrec_run_batch', array( __CLASS__, 'process_batch' ), 10, 2 );
	}

	/**
	 * The check frequencies offered on the Settings screen, mapped to the core
	 * cron schedules that drive them. Every one of them is available to every
	 * install: the cadence is the site owner's choice, never a paid tier.
	 *
	 * @return array<string,string>
	 */
	public static function frequencies() {
		return array(
			'hourly'     => 'hourly',
			'twicedaily' => 'twicedaily',
			'daily'      => 'daily',
		);
	}

	/**
	 * The configured check frequency ('off' when scheduled checks are disabled).
	 *
	 * @return string
	 */
	public static function frequency() {
		$settings = get_option( 'sfrec_settings', array() );
		$value    = isset( $settings['check_frequency'] ) ? (string) $settings['check_frequency'] : 'daily';

		return isset( self::frequencies()[ $value ] ) ? $value : 'off';
	}

	/**
	 * Align the scheduled-check cron event with the configured frequency.
	 * Called on activation, on upgrade and whenever settings are saved.
	 */
	public static function ensure_schedule() {
		$frequency = self::frequency();
		$scheduled = wp_next_scheduled( 'sfrec_scheduled_check' );

		if ( 'off' === $frequency ) {
			if ( $scheduled ) {
				wp_clear_scheduled_hook( 'sfrec_scheduled_check' );
			}
			return;
		}

		$recurrence = wp_get_schedule( 'sfrec_scheduled_check' );
		if ( $scheduled && $recurrence === self::frequencies()[ $frequency ] ) {
			return; // Already running at the requested cadence.
		}

		if ( $scheduled ) {
			wp_clear_scheduled_hook( 'sfrec_scheduled_check' );
		}
		wp_schedule_event( time() + HOUR_IN_SECONDS, self::frequencies()[ $frequency ], 'sfrec_scheduled_check' );
	}

	/**
	 * Scheduled cron entry.
	 */
	public static function cron_scheduled() {
		if ( 'off' === self::frequency() ) {
			return;
		}
		self::start_run( 'scheduled' );
	}

	/**
	 * Post-update cron entry (queued by SFRec_Events ~90s after site changes).
	 */
	public static function cron_post_update() {
		self::start_run( 'post_update' );
	}

	/**
	 * Begin a run: loopback self-check, site-level checks, then page batches.
	 *
	 * @param string $context manual|scheduled|post_update|baseline.
	 * @return string|WP_Error Run token.
	 */
	public static function start_run( $context = 'manual' ) {
		if ( get_transient( 'sfrec_active_run' ) ) {
			return new WP_Error( 'sfrec_run_active', __( 'A check is already running.', 'buildsbyluke-seo-flight-recorder' ) );
		}

		$token = substr( md5( uniqid( 'sfrec', true ) ), 0, 12 );
		set_transient(
			'sfrec_active_run',
			array(
				'token'      => $token,
				'context'    => sanitize_key( $context ),
				'started'    => time(),
				'new_ids'    => array(),
				'resolved'   => array(),
				'fetch_errs' => 0,
			),
			self::RUN_LIFETIME
		);

		$loopback_ok = SFRec_Fetcher::self_check();

		// Site-visibility check works even with a dead loopback (it reads the
		// option directly) — the one alarm that must never depend on HTTP.
		self::check_site_visibility();

		if ( $loopback_ok ) {
			SFRec_Sitewide::run_site_checks();
		}

		self::process_batch( $token, 0 );

		return $token;
	}

	/**
	 * Process one batch of monitored pages, chaining the next when needed.
	 *
	 * @param string $token  Run token.
	 * @param int    $offset Page offset.
	 */
	public static function process_batch( $token, $offset = 0 ) {
		$run = get_transient( 'sfrec_active_run' );
		if ( ! is_array( $run ) || ! isset( $run['token'] ) || $run['token'] !== $token ) {
			return; // Stale chain event.
		}

		$state = get_option( 'sfrec_state', array() );
		if ( empty( $state['loopback_ok'] ) ) {
			// No fetching possible; the impaired state is already stored and
			// surfaced. Close the run without touching snapshots.
			self::finish_run( $run );
			return;
		}

		$pages   = self::pages( $offset, self::BATCH_SIZE );
		$started = time();

		foreach ( $pages as $index => $page ) {
			if ( ( time() - $started ) > self::TIME_BUDGET ) {
				// Budget spent: hand the remainder to the next tick.
				self::chain( $token, $offset + $index );
				set_transient( 'sfrec_active_run', $run, self::RUN_LIFETIME );
				return;
			}
			$run = self::check_page( $page, $run );
		}

		set_transient( 'sfrec_active_run', $run, self::RUN_LIFETIME );

		if ( count( $pages ) === self::BATCH_SIZE && count( self::pages( $offset + self::BATCH_SIZE, 1 ) ) > 0 ) {
			self::chain( $token, $offset + self::BATCH_SIZE );
			return;
		}

		self::finish_run( $run );
	}

	/**
	 * Fetch, parse, diff and record one page.
	 *
	 * @param object $page Page row.
	 * @param array  $run  Run bookkeeping.
	 * @return array Updated run bookkeeping.
	 */
	private static function check_page( $page, $run ) {
		global $wpdb;

		$settings = get_option( 'sfrec_settings', array() );
		$result   = SFRec_Fetcher::fetch( $page->url, array( 'cache_bypass' => ! empty( $settings['cache_bypass'] ) ) );

		if ( is_wp_error( $result ) ) {
			$run['fetch_errs']++;
			$change_id = self::insert_change(
				(int) $page->id,
				'fetch_error',
				'',
				$result->get_error_message(),
				'warning',
				'unexplained',
				array()
			);
			if ( $change_id ) {
				$run['new_ids'][] = $change_id;
			}
			return $run;
		}

		// A previously unreachable page that answers again resolves its open row.
		self::resolve_open( (int) $page->id, 'fetch_error', $run );

		$fields = SFRec_Parser::extract( $result['body'], $result['headers'], $result['status'], $result['redirect_to'] );
		$hash   = SFRec_Parser::hash_fields( $fields );

		$snapshot = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}sfrec_snapshots WHERE page_id = %d AND kind = 'current'", (int) $page->id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.

		if ( $snapshot && $snapshot->fields_hash === $hash ) {
			return $run; // Nothing changed; keep the earlier captured_at.
		}

		if ( $snapshot ) {
			$before = json_decode( (string) $snapshot->fields, true );
			if ( is_array( $before ) ) {
				$since  = strtotime( $snapshot->captured_at . ' UTC' );
				$events = SFRec_Events::since( $since ? $since : 0 );
				$diffs  = SFRec_Differ::diff( self::with_defaults( $before ), $fields, $events, (int) $page->post_id );
				foreach ( $diffs as $diff ) {
					$change_id = self::insert_change(
						(int) $page->id,
						$diff['field'],
						$diff['before'],
						$diff['after'],
						$diff['severity'],
						$diff['classification'],
						$diff['suspects']
					);
					if ( $change_id ) {
						$run['new_ids'][] = $change_id;
					}
				}
				// A page that regained indexability closes its open noindex row.
				if ( ! empty( $before['noindex'] ) && empty( $fields['noindex'] ) ) {
					self::resolve_open( (int) $page->id, 'noindex', $run );
				}
			}
		}

		// Upsert the current snapshot.
		$data = array(
			'page_id'     => (int) $page->id,
			'kind'        => 'current',
			'fields'      => wp_json_encode( $fields ),
			'fields_hash' => $hash,
			'captured_at' => gmdate( 'Y-m-d H:i:s' ),
		);
		if ( $snapshot ) {
			$wpdb->update( $wpdb->prefix . 'sfrec_snapshots', $data, array( 'id' => (int) $snapshot->id ), array( '%d', '%s', '%s', '%s', '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		} else {
			$wpdb->insert( $wpdb->prefix . 'sfrec_snapshots', $data, array( '%d', '%s', '%s', '%s', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		}

		return $run;
	}

	/**
	 * Close out a run: bookkeeping, retention, alerts.
	 *
	 * @param array $run Run bookkeeping.
	 */
	private static function finish_run( $run ) {
		delete_transient( 'sfrec_active_run' );

		$state               = get_option( 'sfrec_state', array() );
		$state['last_check'] = time();
		update_option( 'sfrec_state', $state, false );

		self::prune();

		$new_ids  = isset( $run['new_ids'] ) ? array_map( 'intval', (array) $run['new_ids'] ) : array();
		$resolved = isset( $run['resolved'] ) ? array_map( 'intval', (array) $run['resolved'] ) : array();
		SFRec_Alerts::dispatch( $new_ids, $resolved, isset( $run['context'] ) ? $run['context'] : 'manual' );
	}

	/**
	 * The "Discourage search engines" tripwire — option-based, HTTP-free.
	 */
	private static function check_site_visibility() {
		global $wpdb;
		$discouraged = ( '0' === (string) get_option( 'blog_public' ) );
		$open_row_id = (int) $wpdb->get_var( "SELECT id FROM {$wpdb->prefix}sfrec_changes WHERE page_id = 0 AND field = 'site_visibility' AND status IN ('new','seen') LIMIT 1" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.

		if ( $discouraged && 0 === $open_row_id ) {
			self::insert_change( 0, 'site_visibility', __( 'visible to search engines', 'buildsbyluke-seo-flight-recorder' ), __( 'DISCOURAGED (blog_public=0)', 'buildsbyluke-seo-flight-recorder' ), 'critical', 'site_change', array() );
		} elseif ( ! $discouraged && $open_row_id > 0 ) {
			$wpdb->update( $wpdb->prefix . 'sfrec_changes', array( 'status' => 'resolved' ), array( 'id' => $open_row_id ), array( '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		}
	}

	/**
	 * Insert a change row unless an identical one is already open (dedup), or
	 * the page+field pair is muted.
	 *
	 * @param int      $page_id        Page ID (0 = site-level).
	 * @param string   $field          Field key.
	 * @param string   $before         Before value.
	 * @param string   $after          After value.
	 * @param string   $severity       critical|warning|info.
	 * @param string   $classification Classification.
	 * @param string[] $suspects       Correlated event labels.
	 * @return int Change ID (0 when deduped/muted).
	 */
	public static function insert_change( $page_id, $field, $before, $after, $severity, $classification, $suspects ) {
		global $wpdb;

		$mutes = get_option( 'sfrec_mutes', array() );
		if ( is_array( $mutes ) && in_array( $page_id . '|' . $field, $mutes, true ) ) {
			return 0;
		}

		$exists = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}sfrec_changes WHERE page_id = %d AND field = %s AND after_val = %s AND status IN ('new','seen') LIMIT 1", $page_id, $field, (string) $after ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		if ( $exists > 0 ) {
			return 0;
		}

		$wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
			$wpdb->prefix . 'sfrec_changes',
			array(
				'page_id'        => $page_id,
				'field'          => $field,
				'before_val'     => (string) $before,
				'after_val'      => (string) $after,
				'severity'       => $severity,
				'classification' => $classification,
				'suspects'       => wp_json_encode( array_slice( (array) $suspects, 0, 12 ) ),
				'detected_at'    => gmdate( 'Y-m-d H:i:s' ),
				'status'         => 'new',
			),
			array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
		);

		return (int) $wpdb->insert_id;
	}

	/**
	 * Resolve any open row for a page+field and note it in the run.
	 *
	 * @param int    $page_id Page ID.
	 * @param string $field   Field key.
	 * @param array  $run     Run bookkeeping (by reference through return).
	 */
	private static function resolve_open( $page_id, $field, &$run ) {
		global $wpdb;
		$open_id = (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}sfrec_changes WHERE page_id = %d AND field = %s AND status IN ('new','seen') LIMIT 1", $page_id, $field ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		if ( $open_id > 0 ) {
			$wpdb->update( $wpdb->prefix . 'sfrec_changes', array( 'status' => 'resolved' ), array( 'id' => $open_id ), array( '%s' ), array( '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
			$run['resolved'][] = $open_id;
		}
	}

	/**
	 * Flight-log housekeeping, entirely the site owner's choice: the Settings
	 * screen offers "keep everything" (the default) or an age limit. Nothing is
	 * capped by this plugin. Open critical rows are never pruned regardless: an
	 * unresolved noindex must not age out of sight.
	 */
	private static function prune() {
		global $wpdb;

		$settings = get_option( 'sfrec_settings', array() );
		$days     = isset( $settings['retention_days'] ) ? (int) $settings['retention_days'] : 0;

		if ( $days <= 0 ) {
			return; // Keep everything.
		}

		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		$wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}sfrec_changes WHERE detected_at < %s AND NOT (severity = 'critical' AND status IN ('new','seen'))", $cutoff ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table retention.
	}

	/**
	 * Monitored pages. There is no limit on how many pages a site may monitor;
	 * `$limit` exists purely so a run can be processed in batches.
	 *
	 * @param int $offset Offset.
	 * @param int $limit  Batch size (0 = every remaining page).
	 * @return object[]
	 */
	public static function pages( $offset = 0, $limit = 0 ) {
		global $wpdb;

		$offset = max( 0, (int) $offset );
		$limit  = max( 0, (int) $limit );

		if ( $limit > 0 ) {
			return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}sfrec_pages WHERE is_active = 1 ORDER BY id ASC LIMIT %d OFFSET %d", $limit, $offset ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		}

		if ( $offset > 0 ) {
			// MySQL has no OFFSET without LIMIT. PHP_INT_MAX stands in for "all
			// remaining rows" and stays inside the signed 64-bit range that both
			// MySQL and SQLite accept.
			return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}sfrec_pages WHERE is_active = 1 ORDER BY id ASC LIMIT %d OFFSET %d", PHP_INT_MAX, $offset ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
		}

		return (array) $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}sfrec_pages WHERE is_active = 1 ORDER BY id ASC" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- own table.
	}

	/**
	 * Chain the next batch.
	 *
	 * @param string $token  Run token.
	 * @param int    $offset Next offset.
	 */
	private static function chain( $token, $offset ) {
		if ( ! wp_next_scheduled( 'sfrec_run_batch', array( $token, $offset ) ) ) {
			wp_schedule_single_event( time() + 15, 'sfrec_run_batch', array( $token, $offset ) );
		}
	}

	/**
	 * Guarantee every parser key exists on a stored (possibly older-schema)
	 * snapshot before diffing.
	 *
	 * @param array $fields Stored fields.
	 * @return array
	 */
	private static function with_defaults( $fields ) {
		return array_merge( SFRec_Parser::extract( '' ), is_array( $fields ) ? $fields : array() );
	}
}
