<?php
/**
 * Cache module — Preloader (sitemap warmup + on-hover link prefetch)
 *
 * Two parts:
 *
 *   - **Sitemap warmup**: an hourly cron walks the site's XML sitemap, batches
 *     URLs (10 per run) and HTTP-GETs them so the page-cache file is created
 *     before real visitors arrive. Queue is persisted in a transient.
 *
 *   - **Link prefetch on hover**: enqueues `preload-links.js` which uses the
 *     standard `<link rel="prefetch">` strategy on hover/focus over internal
 *     links. Inspired by instant.page (MIT, no code copied).
 *
 * @package BlaminhorEssentials
 */

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

class Blaminhor_Essentials_Cache_Preloader {

	const QUEUE_TRANSIENT  = 'blaminhor_cache_preload_queue';
	const QUEUE_TTL        = DAY_IN_SECONDS;
	const BATCH_SIZE       = 10;
	const CRON_HOOK        = 'blaminhor_essentials_cache_preload';
	const LAST_RUN_OPTION  = 'blaminhor_essentials_cache_preload_last_run';
	const QUEUE_TOTAL_OPT  = 'blaminhor_essentials_cache_preload_queue_total';

	/** @var Blaminhor_Essentials_Module_Cache */
	private $parent;

	public function __construct( Blaminhor_Essentials_Module_Cache $parent ) {
		$this->parent = $parent;
	}

	public function register_hooks() {
		add_action( self::CRON_HOOK, array( $this, 'run_batch' ) );

		// Schedule on activation-equivalent moments. We rely on register_hooks()
		// running on every active page request (cheap, idempotent).
		if ( $this->parent->get_setting( 'preload_sitemap_enabled', false ) ) {
			if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
				wp_schedule_event( time() + 60, 'hourly', self::CRON_HOOK );
			}
		} else {
			$ts = wp_next_scheduled( self::CRON_HOOK );
			if ( $ts ) {
				wp_unschedule_event( $ts, self::CRON_HOOK );
			}
		}

		// Re-fill the queue when the cache is fully purged, then kick the
		// cron immediately so the warming starts straight away. Two separate
		// handlers on the same action keep the responsibilities clear:
		//   priority 10 — refill_queue: rebuild the URL list from sitemap
		//   priority 20 — kick_immediate: schedule + spawn the cron worker
		add_action( 'blaminhor_essentials_cache_purged_all', array( $this, 'refill_queue' ), 10 );
		add_action( 'blaminhor_essentials_cache_purged_all', array( $this, 'kick_immediate' ), 20 );

		// Hover prefetch JS.
		add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue_hover_prefetch' ) );
	}

	/**
	 * Force the preload cron to run NOW instead of at the next hourly tick.
	 *
	 * Two-step mechanism:
	 *   1. Schedule a single event at time() (immediately due) with a
	 *      distinct args signature `[true]` — distinct so WP's duplicate
	 *      detection in wp_schedule_single_event() does NOT consider this a
	 *      near-duplicate of the recurring hourly event (whose args are []).
	 *      The args=[true] is also passed straight to run_batch() as $force,
	 *      letting it bypass the preload_sitemap_enabled toggle in the
	 *      "user clicked Purge" flow even if scheduled preload is off.
	 *   2. spawn_cron() — pings wp-cron.php with a non-blocking 0.01s POST,
	 *      so WP picks up the due event in a background request rather than
	 *      waiting for the next visitor to trigger spawn_cron() naturally.
	 *      No-ops when DISABLE_WP_CRON is true (the user has wired their
	 *      own cron — system cron / WP-CLI — and we'd just be in the way),
	 *      and respects the 60s lock so rapid successive purges don't all
	 *      try to spawn.
	 *
	 * Without this, "Purge cache" left the banner saying
	 * "Next run in 44 minutes" — confusing for an admin who just asked the
	 * cache to refresh. The cron still ran on schedule, just up to an hour
	 * later, and any visitor in between paid the cold-cache cost.
	 */
	public function kick_immediate() {
		if ( ! $this->parent->get_setting( 'preload_sitemap_enabled', false ) ) {
			return;
		}
		wp_schedule_single_event( time(), self::CRON_HOOK, array( true ) );
		if ( function_exists( 'spawn_cron' ) ) {
			spawn_cron();
		}
	}

	/**
	 * Process the next batch from the queue.
	 */
	public function run_batch( $force = false ) {
		// The cron-driven path respects the user's "preload from sitemap"
		// toggle. The admin-triggered "Run preload now" button passes
		// $force=true so it can warm the cache on demand even when the
		// scheduled preload is intentionally off.
		if ( ! $force && ! $this->parent->get_setting( 'preload_sitemap_enabled', false ) ) {
			return;
		}

		$queue = get_transient( self::QUEUE_TRANSIENT );
		if ( ! is_array( $queue ) || empty( $queue ) ) {
			$queue = $this->build_queue();
			if ( empty( $queue ) ) {
				return;
			}
		}

		// Honour the user-configurable batch size (Preload tab → "Pages per
		// run"). The constant BATCH_SIZE stays as the fallback when the
		// setting is missing — same default as before so existing sites
		// keep their behaviour until the user opts into a bigger batch.
		$batch_size = (int) $this->parent->get_setting( 'preload_batch_size', self::BATCH_SIZE );
		if ( $batch_size < 1 ) {
			$batch_size = self::BATCH_SIZE;
		}
		$batch = array_splice( $queue, 0, $batch_size );
		set_transient( self::QUEUE_TRANSIENT, $queue, self::QUEUE_TTL );

		foreach ( $batch as $url ) {
			// Fire-and-forget GET. The cache file is written by the
			// Cache_Page_Cache shutdown hook on the receiving end.
			wp_remote_get( $url, array(
				'timeout'    => 15,
				'redirection'=> 5,
				'sslverify'  => false,
				'user-agent' => 'Blaminhor-Cache-Preloader/1.0',
				'blocking'   => true, // We do want to wait so the cache file is on disk before next batch.
			) );
		}

		// Persist a small footprint of the last run so the admin banner can
		// show "last batch warmed N URLs T minutes ago" without spelunking
		// transients or scanning logs.
		update_option( self::LAST_RUN_OPTION, array(
			'ts'        => time(),
			'count'     => count( $batch ),
			'remaining' => count( $queue ),
		), false );

		blaminhor_essentials_log( sprintf( 'Cache preloader: warmed %d URLs (%d remaining)', count( $batch ), count( $queue ) ) );

		// Chain the next batch if there's still work to do, so a 33-URL queue
		// drains in the same minute as the purge that triggered it instead
		// of waiting hours for successive hourly ticks. The args=[true]
		// signature keeps each kicked event distinct from the recurring
		// hourly's args=[] (no near-duplicate detection) and bypasses the
		// preload_sitemap_enabled gate just like the original kick.
		if ( ! empty( $queue ) && $force ) {
			wp_schedule_single_event( time(), self::CRON_HOOK, array( true ) );
			if ( function_exists( 'spawn_cron' ) ) {
				spawn_cron();
			}
		}
	}

	/**
	 * Refill the queue (called after purge_all and at first run).
	 */
	public function refill_queue() {
		$urls = $this->build_queue();
		if ( ! empty( $urls ) ) {
			set_transient( self::QUEUE_TRANSIENT, $urls, self::QUEUE_TTL );
			update_option( self::QUEUE_TOTAL_OPT, count( $urls ), false );
			// Scheduling + spawning the cron is handled by kick_immediate(),
			// which is registered as a separate handler on the same purge
			// action so refill_queue() can be reused by ajax_preload_now /
			// ajax_preload_start (those run the batch synchronously and do
			// not want a parallel cron spawn).
		} else {
			delete_transient( self::QUEUE_TRANSIENT );
			delete_option( self::QUEUE_TOTAL_OPT );
		}
	}

	/**
	 * Snapshot of the preloader's runtime state for the admin banner.
	 *
	 * @return array{
	 *     enabled: bool,
	 *     queue_size: int,
	 *     queue_total: int,
	 *     last_run_ts: int,
	 *     last_batch_count: int,
	 *     next_run_ts: int,
	 *     cron_late: bool
	 * }
	 */
	public function get_status() {
		$queue       = get_transient( self::QUEUE_TRANSIENT );
		$queue_size  = is_array( $queue ) ? count( $queue ) : 0;
		$queue_total = (int) get_option( self::QUEUE_TOTAL_OPT, 0 );

		$last        = get_option( self::LAST_RUN_OPTION, array() );
		$last_run_ts = is_array( $last ) && isset( $last['ts'] ) ? (int) $last['ts'] : 0;
		$last_count  = is_array( $last ) && isset( $last['count'] ) ? (int) $last['count'] : 0;

		// Two co-existing schedules can target this hook:
		//   - the recurring hourly  (args [])
		//   - a one-shot kick after a purge (args [true]) injected by
		//     kick_immediate() so the warming starts straight away
		// wp_next_scheduled() filters by args — without considering both we
		// would always show the hourly's timestamp and the banner would say
		// "Next run in 44 minutes" right after a purge that already
		// scheduled an immediate run. Pick the soonest non-zero of the two.
		$candidates = array_filter( array(
			(int) wp_next_scheduled( self::CRON_HOOK ),
			(int) wp_next_scheduled( self::CRON_HOOK, array( true ) ),
		) );
		$next_run = $candidates ? (int) min( $candidates ) : 0;

		// Cron is "late" when the next scheduled tick is in the past by more
		// than the hourly interval — typically means WP-cron isn't running
		// (DISABLE_WP_CRON=true with no system cron, or a stuck heartbeat).
		$cron_late = false;
		if ( $next_run > 0 && $next_run < ( time() - HOUR_IN_SECONDS ) ) {
			$cron_late = true;
		}

		return array(
			'enabled'          => (bool) $this->parent->get_setting( 'preload_sitemap_enabled', false ),
			'queue_size'       => $queue_size,
			'queue_total'      => $queue_total,
			'last_run_ts'      => $last_run_ts,
			'last_batch_count' => $last_count,
			'next_run_ts'      => $next_run,
			'cron_late'        => $cron_late,
		);
	}

	/**
	 * Build the URL queue from the sitemap. Falls back to home + 20 latest posts
	 * if no sitemap is reachable.
	 *
	 * @return string[]
	 */
	private function build_queue() {
		$urls = array();

		// 1. Fastest, most accurate path: the plugin's own SEO/GEO module
		//    knows exactly which post types are indexed and which posts are
		//    flagged noindex — short-circuit the HTTP roundtrip to /sitemap.xml
		//    (which is even slower behind a CDN) and ask the module directly.
		if ( function_exists( 'blaminhor_essentials' ) ) {
			$plugin = blaminhor_essentials();
			if ( is_object( $plugin ) && method_exists( $plugin, 'is_module_active' ) && $plugin->is_module_active( 'seo-manager' ) ) {
				$active = $plugin->get_active_modules();
				if ( isset( $active['seo-manager'] ) && method_exists( $active['seo-manager'], 'get_sitemap_urls' ) ) {
					$urls = (array) $active['seo-manager']->get_sitemap_urls();
				}
			}
		}

		// 2. Fall back to fetching whatever sitemap URL the user configured —
		//    or, if blank, whichever URL detect_sitemap_url() guesses.
		if ( empty( $urls ) ) {
			$sitemap_url = trim( (string) $this->parent->get_setting( 'preload_sitemap_url', '' ) );
			if ( '' === $sitemap_url ) {
				$sitemap_url = $this->detect_sitemap_url();
			}
			if ( '' !== $sitemap_url ) {
				$urls = $this->fetch_sitemap_urls( $sitemap_url );
			}
		}

		// 3. Last resort: home + 20 latest posts so we never end up with a
		//    completely empty queue on a fresh install.
		if ( empty( $urls ) ) {
			$urls[] = home_url( '/' );
			$latest = get_posts( array(
				'numberposts' => 20,
				'post_status' => 'publish',
				'fields'      => 'ids',
			) );
			foreach ( (array) $latest as $id ) {
				$pl = get_permalink( (int) $id );
				if ( is_string( $pl ) ) {
					$urls[] = $pl;
				}
			}
		}

		// Strict sanitisation: every entry must be a non-empty string AND a
		// valid HTTP/HTTPS URL. Anything else (booleans, "false" strings,
		// permalinks that came back malformed) gets dropped before it can
		// reach the live preload table.
		$clean = array();
		foreach ( $urls as $u ) {
			if ( ! is_string( $u ) || '' === $u ) {
				continue;
			}
			if ( ! preg_match( '#^https?://#i', $u ) ) {
				continue;
			}
			$clean[] = $u;
		}
		return array_values( array_unique( $clean ) );
	}

	/**
	 * Attempt to find a sitemap URL: query the seo-manager module first
	 * (if loaded), then fall back to /sitemap.xml and /wp-sitemap.xml.
	 *
	 * @return string Empty if nothing detected.
	 */
	private function detect_sitemap_url() {
		// 1. Prefer the plugin's own SEO/GEO module — it publishes a custom
		//    sitemap at /sitemap.xml that knows every public post type the
		//    user has explicitly indexed. More accurate than guessing.
		if ( function_exists( 'blaminhor_essentials' ) ) {
			$plugin = blaminhor_essentials();
			if ( is_object( $plugin ) && method_exists( $plugin, 'is_module_active' ) && $plugin->is_module_active( 'seo-manager' ) ) {
				return home_url( '/sitemap.xml' );
			}
		}
		// 2. WP core 5.5+ sitemap.
		if ( class_exists( 'WP_Sitemaps' ) ) {
			return home_url( '/wp-sitemap.xml' );
		}
		// 3. Last-resort guess — build_queue() falls back to home + recent
		//    posts if this URL 404s.
		return home_url( '/sitemap.xml' );
	}

	/**
	 * Fetch a sitemap (xml or sitemapindex) and return the list of <loc> URLs.
	 *
	 * @param string $url
	 * @return string[]
	 */
	private function fetch_sitemap_urls( $url ) {
		$response = wp_remote_get( $url, array( 'timeout' => 15, 'sslverify' => false ) );
		if ( is_wp_error( $response ) ) {
			return array();
		}
		$code = wp_remote_retrieve_response_code( $response );
		if ( 200 !== (int) $code ) {
			return array();
		}
		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			return array();
		}

		$urls = array();

		// First, look for sitemap index (nested sitemaps).
		if ( preg_match_all( '#<sitemap>\s*<loc>([^<]+)</loc>#i', $body, $m ) ) {
			$child_sitemaps = array_map( 'trim', $m[1] );
			// Cap at 5 children to avoid runaway requests.
			foreach ( array_slice( $child_sitemaps, 0, 5 ) as $child ) {
				$urls = array_merge( $urls, $this->fetch_sitemap_urls( $child ) );
			}
			// If we found children, return them — most index sitemaps don't carry direct <url> entries.
			if ( ! empty( $urls ) ) {
				return $urls;
			}
		}

		// Plain sitemap.
		if ( preg_match_all( '#<url>\s*<loc>([^<]+)</loc>#i', $body, $m ) ) {
			foreach ( $m[1] as $u ) {
				$u = trim( html_entity_decode( $u ) );
				if ( '' !== $u ) {
					$urls[] = $u;
				}
			}
		}

		return $urls;
	}

	public function maybe_enqueue_hover_prefetch() {
		// Hover prefetch only matters for anonymous visitors browsing the
		// public site — useless overhead for an admin debugging the front-end.
		// Replaces the previous is_admin()-only check.
		if ( $this->parent->should_skip_runtime_optimizations() ) {
			return;
		}
		if ( ! $this->parent->get_setting( 'preload_links_hover', true ) ) {
			return;
		}
		wp_enqueue_script(
			'blaminhor-essentials-cache-preload-links',
			BLAMINHOR_ESSENTIALS_PLUGIN_URL . 'assets/js/frontend/preload-links.js',
			array(),
			BLAMINHOR_ESSENTIALS_VERSION,
			array( 'in_footer' => true, 'strategy' => 'defer' )
		);
	}
}
