<?php
/**
 * Background scanner: collects links from content and checks them in
 * small, throttled batches via WP-Cron.
 *
 * The scan is a cycle with two phases:
 *  - collect: walk published posts, extract links into the links table.
 *  - check:   check pending URLs (batch_size per run, HEAD with GET
 *             fallback), pausing between requests.
 *
 * Every run is bounded by a time budget and skipped entirely when the
 * server load is high, so the scanner never competes with real visitors.
 *
 * @package 404Zen
 */

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

class LGBF_Scanner {

	const STATE_OPTION = 'lgbf_scan_state';

	/**
	 * Posts processed per run in the collect phase.
	 */
	const POSTS_PER_RUN = 20;

	/**
	 * Seconds slept between two HTTP checks.
	 */
	const PAUSE_BETWEEN_CHECKS_US = 250000;

	/**
	 * Default per-run wall-clock budget in seconds.
	 */
	const TIME_BUDGET = 20;

	/**
	 * Hooks.
	 */
	public static function init() {
		add_action( 'lgbf_scan_batch', array( __CLASS__, 'run_batch' ) );
		add_action( 'save_post', array( __CLASS__, 'on_save_post' ), 10, 2 );
		add_action( 'deleted_post', array( __CLASS__, 'on_deleted_post' ) );
	}

	/**
	 * Cron entry point: one bounded unit of work.
	 */
	public static function run_batch() {
		if ( self::is_server_busy() ) {
			return; // Skip this run; cron will try again next interval.
		}

		$state = self::get_state();

		if ( 'idle' === $state['phase'] ) {
			if ( ! self::cycle_due() ) {
				return;
			}
			LGBF_Links_Store::reset_for_new_cycle();
			$state = array(
				'phase'         => 'collect',
				'last_post_id'  => 0,
				'cycle_started' => time(),
			);
			self::save_state( $state );
		}

		$deadline = time() + self::time_budget();

		if ( 'collect' === $state['phase'] ) {
			$state = self::run_collect( $state, $deadline );
			self::save_state( $state );
		}

		if ( 'check' === $state['phase'] && time() < $deadline ) {
			$state = self::run_check( $state, $deadline );
			self::save_state( $state );
		}
	}

	/**
	 * Collect phase: extract links from the next slice of posts.
	 *
	 * @param array $state    Scan state.
	 * @param int   $deadline Unix timestamp to stop at.
	 * @return array Updated state.
	 */
	private static function run_collect( $state, $deadline ) {
		while ( time() < $deadline ) {
			$posts = self::get_next_posts( (int) $state['last_post_id'], self::POSTS_PER_RUN );

			if ( empty( $posts ) ) {
				$state['phase'] = 'check';
				return $state;
			}

			foreach ( $posts as $post ) {
				$links = LGBF_Link_Extractor::extract( $post->post_content, get_permalink( $post ) );

				LGBF_Links_Store::sync_post_links( $post->ID, self::filter_links( $links ) );
				$state['last_post_id'] = $post->ID;
			}

			if ( count( $posts ) < self::POSTS_PER_RUN ) {
				$state['phase'] = 'check';
				return $state;
			}
		}

		return $state;
	}

	/**
	 * Applies the "what to check" settings and URL exclusions to an
	 * extracted link set.
	 *
	 * @param array[] $links Items from LGBF_Link_Extractor::extract().
	 * @return array[]
	 */
	private static function filter_links( $links ) {
		$exclude_patterns = (array) LGBF_Settings::get( 'exclude_urls' );
		$check_internal   = (bool) LGBF_Settings::get( 'check_internal' );
		$check_external   = (bool) LGBF_Settings::get( 'check_external' );
		$check_images     = (bool) LGBF_Settings::get( 'check_images' );
		$home_host        = wp_parse_url( home_url(), PHP_URL_HOST );

		$filtered = array();
		foreach ( $links as $link ) {
			if ( 'image' === $link['type'] && ! $check_images ) {
				continue;
			}
			$is_internal = ( wp_parse_url( $link['url'], PHP_URL_HOST ) === $home_host );
			if ( $is_internal && ! $check_internal ) {
				continue;
			}
			if ( ! $is_internal && ! $check_external ) {
				continue;
			}
			if ( LGBF_Link_Extractor::is_excluded( $link['url'], $exclude_patterns ) ) {
				continue;
			}
			$filtered[] = $link;
		}

		return $filtered;
	}

	/**
	 * Fetches the next batch of posts by ascending ID.
	 *
	 * @param int $after_id Fetch posts with ID greater than this.
	 * @param int $limit    Batch size.
	 * @return WP_Post[]
	 */
	private static function get_next_posts( $after_id, $limit ) {
		global $wpdb;

		$post_types = LGBF_Settings::scannable_post_types();
		if ( empty( $post_types ) ) {
			return array();
		}

		$placeholders = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );
		$sql          = $wpdb->prepare(
			"SELECT ID FROM {$wpdb->posts}
			 WHERE ID > %d AND post_status = 'publish' AND post_type IN ({$placeholders})
			 ORDER BY ID ASC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			array_merge( array( (int) $after_id ), $post_types, array( (int) $limit ) )
		);

		$ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		return array_values( array_filter( array_map( 'get_post', array_map( 'intval', (array) $ids ) ) ) );
	}

	/**
	 * Check phase: verify up to batch_size pending URLs.
	 *
	 * @param array $state    Scan state.
	 * @param int   $deadline Unix timestamp to stop at.
	 * @return array Updated state.
	 */
	private static function run_check( $state, $deadline ) {
		$batch_size = (int) LGBF_Settings::get( 'batch_size' );
		$pending    = LGBF_Links_Store::get_pending_urls( $batch_size );

		if ( empty( $pending ) ) {
			$state['phase'] = 'idle';
			update_option( 'lgbf_last_cycle_completed', time(), false );
			return $state;
		}

		$checked = 0;
		foreach ( $pending as $row ) {
			if ( time() >= $deadline ) {
				break;
			}

			$result = LGBF_Checker::check( $row['url'] );
			LGBF_Links_Store::apply_check_result( $row['url_hash'], $result );
			$checked++;

			// Breathe between requests so bursts never hammer the server.
			if ( $checked < count( $pending ) ) {
				usleep( self::PAUSE_BETWEEN_CHECKS_US );
			}
		}

		// If the whole batch fit into this run and nothing is left, the next
		// run flips to idle; leaving the phase as 'check' is always safe.
		return $state;
	}

	/**
	 * Whether a new scan cycle should start.
	 *
	 * @return bool
	 */
	public static function cycle_due() {
		$last = (int) get_option( 'lgbf_last_cycle_completed', 0 );
		if ( ! $last ) {
			return true;
		}

		return ( time() - $last ) >= self::frequency_seconds( LGBF_Settings::get( 'scan_frequency' ) );
	}

	/**
	 * Maps a frequency slug to seconds.
	 *
	 * @param string $frequency hourly|twicedaily|daily|weekly.
	 * @return int
	 */
	public static function frequency_seconds( $frequency ) {
		$map = array(
			'hourly'     => HOUR_IN_SECONDS,
			'twicedaily' => 12 * HOUR_IN_SECONDS,
			'daily'      => DAY_IN_SECONDS,
			'weekly'     => WEEK_IN_SECONDS,
		);

		return isset( $map[ $frequency ] ) ? $map[ $frequency ] : DAY_IN_SECONDS;
	}

	/**
	 * Load-based throttle: true when the 1-minute load average exceeds the
	 * configured threshold (or the CPU count when set to auto).
	 *
	 * @return bool
	 */
	public static function is_server_busy() {
		if ( ! function_exists( 'sys_getloadavg' ) ) {
			return false;
		}

		$load = sys_getloadavg();
		if ( ! is_array( $load ) || ! isset( $load[0] ) ) {
			return false;
		}

		return self::load_exceeds_threshold( (float) $load[0], (int) LGBF_Settings::get( 'max_load' ) );
	}

	/**
	 * Pure comparison, unit-testable.
	 *
	 * @param float $load_1m   1-minute load average.
	 * @param int   $threshold Configured threshold; 0 = auto (CPU count, min 2).
	 * @return bool
	 */
	public static function load_exceeds_threshold( $load_1m, $threshold ) {
		if ( $threshold <= 0 ) {
			$threshold = max( 2, self::cpu_count() );
		}

		return $load_1m > (float) $threshold;
	}

	/**
	 * Best-effort CPU count.
	 *
	 * @return int
	 */
	public static function cpu_count() {
		static $count = null;

		if ( null !== $count ) {
			return $count;
		}

		$count = 2;
		if ( is_readable( '/proc/cpuinfo' ) ) {
			$cpuinfo = file_get_contents( '/proc/cpuinfo' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
			$found   = preg_match_all( '/^processor\s*:/m', (string) $cpuinfo, $matches );
			if ( $found > 0 ) {
				$count = (int) $found;
			}
		}

		return $count;
	}

	/**
	 * Per-run wall clock budget, respecting PHP's max_execution_time.
	 *
	 * @return int Seconds.
	 */
	public static function time_budget() {
		$budget = self::TIME_BUDGET;

		$max_execution = (int) ini_get( 'max_execution_time' );
		if ( $max_execution > 0 ) {
			$budget = min( $budget, max( 5, $max_execution - 5 ) );
		}

		/**
		 * Filters the scanner's per-run time budget in seconds.
		 *
		 * @param int $budget Seconds.
		 */
		return (int) apply_filters( 'lgbf_scan_time_budget', $budget );
	}

	/**
	 * Re-collects links when a post is saved so new URLs enter the queue quickly.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 */
	public static function on_save_post( $post_id, $post ) {
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}
		if ( 'publish' !== $post->post_status ) {
			return;
		}
		if ( ! in_array( $post->post_type, LGBF_Settings::scannable_post_types(), true ) ) {
			return;
		}

		$links = LGBF_Link_Extractor::extract( $post->post_content, get_permalink( $post ) );

		LGBF_Links_Store::sync_post_links( $post_id, self::filter_links( $links ) );
	}

	/**
	 * Cleans up when a post is deleted.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function on_deleted_post( $post_id ) {
		LGBF_Links_Store::delete_post_links( $post_id );
	}

	/**
	 * @return array Scan state with defaults.
	 */
	public static function get_state() {
		$state = get_option( self::STATE_OPTION, array() );
		if ( ! is_array( $state ) ) {
			$state = array();
		}

		return wp_parse_args(
			$state,
			array(
				'phase'         => 'idle',
				'last_post_id'  => 0,
				'cycle_started' => 0,
			)
		);
	}

	/**
	 * Persists scan state.
	 *
	 * @param array $state State.
	 */
	public static function save_state( $state ) {
		update_option( self::STATE_OPTION, $state, false );
	}

	/**
	 * Starts a fresh cycle immediately (used by the "Scan now" button).
	 */
	public static function request_immediate_scan() {
		update_option( 'lgbf_last_cycle_completed', 0, false );
		self::save_state(
			array(
				'phase'         => 'idle',
				'last_post_id'  => 0,
				'cycle_started' => 0,
			)
		);

		if ( ! wp_next_scheduled( 'lgbf_scan_batch' ) ) {
			LGBF_Cron::schedule_events();
		}
		wp_schedule_single_event( time() + 5, 'lgbf_scan_batch' );
	}
}
