<?php

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

/**
 * WP-Cron scheduling and handlers for screenshot generation.
 *
 * Two cron hooks for individual posts:
 *   doig_take_screenshot      — single event per post; submits a proxy job.
 *   doig_daily_refresh_check  — recurring daily; queues stale posts for refresh.
 *
 * One cron hook for bulk and background operations:
 *   doig_process_queue        — runs three passes in sequence:
 *                               1. Submission retry pass (retry failed proxy submissions)
 *                               2. Bulk screenshot pass (capture queued posts)
 *                               3. Polling pass (check pending jobs > 10 min old for callbacks)
 *
 * One cron hook for cache hygiene (2.4.0 M5):
 *   doig_orphan_sweep         — recurring weekly; finds cached screenshots whose
 *                               resolved Source mode no longer needs them, marks
 *                               them with `_doig_orphaned_at`, and purges any
 *                               that have been orphaned for 14+ days.
 *
 * One cron hook for dedicated polling (2.4.1):
 *   doig_poll_pending         — recurring every 15 minutes (custom 'doig_15min'
 *                               schedule); runs only the callback-polling pass.
 *                               Decouples polling from bulk queue activity so
 *                               pending jobs get checked on a predictable
 *                               schedule even when no bulk work is driving
 *                               WP-Cron.
 *
 * --- Screenshot model (v2.0) ---
 *
 * Screenshots are asynchronous. handle_take_screenshot() submits a job to the
 * PlugUpp proxy and returns immediately. The proxy delivers the result via a
 * signed callback POST to doig/v1/callback (class-proxy-callback.php). If the
 * callback is not received within 10 minutes (e.g. blocked by a security plugin),
 * the polling pass in handle_process_queue() detects the pending job and polls
 * the proxy directly.
 *
 * --- Retry behaviour ---
 *
 * When a proxy submission fails transiently (proxy unreachable, 5xx), the post is
 * added to the doig_submission_retry option. The submission retry pass in
 * handle_process_queue() retries up to 3 total attempts with backoff:
 * 30 s, 2 min, 5 min. After exhaustion, status is set to 'failed' with error code
 * 'proxy_unavailable'.
 *
 * --- WP-Cron execution model and hosting compatibility ---
 *
 * WP-Cron is pseudo-cron: it only runs when WordPress receives a page request
 * (or when a real server-side cron calls wp-cron.php). All scheduled events are
 * processed sequentially in a single PHP process.
 *
 * This plugin must work correctly across all hosting environments:
 *
 *   Standard WP-Cron (shared hosting, page-load-triggered):
 *     wp-cron.php is called as a loopback HTTP request and runs as a web process.
 *     PHP's max_execution_time measures CPU time on Linux (I/O wait does not count),
 *     so a single API call (~15-75 seconds of wall time) is usually safe. However,
 *     server-level timeouts (Apache Timeout, PHP-FPM request_terminate_timeout) ARE
 *     wall-clock and can kill the process on some hosts. wp-cron.php calls
 *     set_time_limit(0) to try to remove the PHP limit, but this may not override
 *     server-level configuration.
 *
 *   Server-side cron (e.g. xCloud with DISABLE_WP_CRON=true):
 *     wp-cron.php is called directly on a schedule; no web timeout applies.
 *
 * Bulk operations (force_regenerate_all, handle_daily_refresh) use a queue stored
 * in a DB option (doig_regen_queue) rather than scheduling one cron event per post.
 * The queue processor (handle_process_queue) runs a time-boxed loop: it processes
 * posts sequentially until either the queue is empty or a wall-clock budget (default
 * 60 seconds) is exhausted, then stops and lets the next cron run continue. This
 * balances throughput (multiple posts per run) against timeout safety (stays well
 * within typical server limits). See handle_process_queue() for full detail.
 *
 * Individual post operations (first publish, post_updated, sidebar Regenerate Now)
 * continue to use per-post doig_take_screenshot events, which are also safe because
 * they handle exactly one post per execution.
 *
 * --- Concurrent cron defence ---
 *
 * On sites with a persistent object cache (Redis, Memcached) the doing_cron option
 * lock can appear stale to a second PHP process, allowing two cron runners to start
 * concurrently. A per-post transient lock in handle_take_screenshot() ensures only
 * one submission proceeds per post at a time. Since 2.6.1 the whole queue
 * processor additionally runs under QUEUE_LOCK (chain hops and cron ticks can
 * genuinely overlap), with the continuation re-armed BEFORE the lock check so
 * a tick that fires into a held lock can never consume the last scheduled
 * event (the locked-tick watchdog leak found in the render engine, 0.3.1).
 *
 * --- Loopback chain (2.6.1) ---
 *
 * The bulk pass no longer waits for the next cron opportunity between
 * time-boxed batches: when a batch stops on its budget with work remaining,
 * it fires a NON-BLOCKING loopback self-request carrying a single-use token
 * (?doig_chain=bulk&doig_chain_token={32-hex}, validated on parse_request)
 * that runs the next batch immediately. WP-Cron stays as the watchdog. This
 * is the pattern from the shared render engine's
 * SPEC-queue-loopback-chain.md, ported - NOT the engine itself (see the
 * 2026-07-03 DECISIONS verdict). Dispatch failure is silent by design: a
 * host that blocks rapid self-requests degrades to exactly the old cron
 * cadence. Filters: doig_chain_enabled, doig_chain_hop_delay,
 * doig_chain_dispatch_timeout. Single-post events additionally get a
 * spawn_cron() nudge in schedule() so first-publish captures do not wait
 * for the next organic page view on quiet sites.
 */
class DOIG_Cron {

	/**
	 * DB option key for the bulk regeneration queue.
	 * Value: a JSON-serialised array of post IDs (WordPress serialises it automatically).
	 */
	const QUEUE_OPTION = 'doig_regen_queue';

	/**
	 * DB option key for the submission retry queue.
	 * Value: array of entries — each a keyed array with post_id, attempt_count,
	 * next_retry_at, and attempts (for exhaustion logging).
	 */
	const RETRY_OPTION = 'doig_submission_retry';

	/**
	 * Seconds a pending post must be waiting before the polling pass queries the proxy.
	 */
	const POLL_THRESHOLD = 600; // 10 minutes

	/**
	 * Seconds after a successful single-post submission before the guaranteed polling
	 * event fires (just past the poll threshold).
	 */
	const POLL_GUARANTEE_DELAY = 720; // 12 minutes

	/**
	 * 2.4.0 M5: cache-flip orphan grace period. A cached screenshot whose
	 * Source mode no longer needs it is first marked with
	 * `_doig_orphaned_at = time()`, then purged by the same weekly sweep
	 * once that timestamp is at least ORPHAN_GRACE_SECONDS in the past.
	 *
	 * 14 days lets a customer flip a content type from Auto-screenshot
	 * to Featured (or any non-screenshot mode), notice they made a
	 * mistake, and flip back without burning quota to re-capture
	 * everything. Confirmed in the Q4 lock; see DECISIONS.md.
	 */
	const ORPHAN_GRACE_SECONDS = 14 * DAY_IN_SECONDS;

	/**
	 * Post types the plugin generates screenshots for.
	 *
	 * Hard-allowlist of user-facing post types only. WordPress and block
	 * themes register a number of internal singular post types
	 * (wp_global_styles, wp_block, wp_template, wp_template_part,
	 * wp_navigation, customize_changeset, ...) that respond at URLs but
	 * are not real renderable pages - the screenshot service loads them
	 * and gets an HTTP 500 from WordPress. Restricting screenshot scheduling to
	 * the two canonical user-facing types stops the misfire at the
	 * source.
	 *
	 * Use this method (not a hardcoded literal) in every new code path
	 * that schedules a screenshot or queries posts for screenshot
	 * eligibility, so a future broadening of supported types is a
	 * one-line change.
	 *
	 * Filter `doig_supported_post_types` is the extension seam used by
	 * `class-cpts__premium_only.php` to inject the user-selected Premium
	 * CPTs (including WooCommerce products when active). Free builds see
	 * just `post` and `page`; Premium adds whatever the customer ticks
	 * in Settings > Custom post types. The daily refresh sweep, Regenerate
	 * All, post-lifecycle hooks, and `is_enabled_for_post_type()` read
	 * from here.
	 *
	 * NOTE: as of 2.3.0 the sidebar enqueue gate, REST meta registration,
	 * and bulk-action registration use `eligible_post_types()` (below)
	 * instead. The override surfaces are independent of the sitewide tick
	 * - a customer can per-post override on any public CPT in Premium,
	 * matching the Posts/Pages parallel where the sidebar appears on
	 * every Post and every Page regardless of sitewide tick state.
	 *
	 * @return array<string>
	 */
	public static function supported_post_types() {
		$base  = array( 'post', 'page' );
		$types = (array) apply_filters( 'doig_supported_post_types', $base );
		return array_values( array_unique( array_filter( array_map( 'sanitize_key', $types ) ) ) );
	}

	/**
	 * Post types where DOIG's per-post override surfaces appear.
	 *
	 * Distinct from supported_post_types() above. Eligible = "the user
	 * can per-post override DOIG behaviour here"; supported = "the user
	 * has opted this type in to automated processing". Posts and Pages
	 * are always eligible. In Premium, every public viewable CPT
	 * registered on the site is eligible (NOT only the ones ticked under
	 * Settings > Custom post types).
	 *
	 * This drives the Gutenberg sidebar enqueue gate
	 * (`class-gutenberg.php`), the REST meta registration
	 * (`class-post-meta.php`), and the bulk-action registration on list
	 * screens (`class-list-actions.php`). It does NOT drive the cron
	 * loops, Regenerate All, or `is_enabled_for_post_type()` - those
	 * stay on supported_post_types() because they're about automated
	 * processing, which requires affirmative opt-in.
	 *
	 * Filter `doig_eligible_post_types` mirrors the
	 * `doig_supported_post_types` extension seam pattern so distributed
	 * builds or developers can extend the eligible set independently.
	 *
	 * Added in 2.3.0 as part of the sidebar consistency fix.
	 *
	 * @return array<string>
	 */
	public static function eligible_post_types() {
		$types = array( 'post', 'page' );

		if ( class_exists( 'DOIG_License' ) && DOIG_License::is_premium() && class_exists( 'DOIG_CPTs' ) ) {
			$types = array_merge( $types, array_keys( DOIG_CPTs::available_cpts() ) );
		}

		$types = (array) apply_filters( 'doig_eligible_post_types', $types );
		return array_values( array_unique( array_filter( array_map( 'sanitize_key', $types ) ) ) );
	}

	/**
	 * Maximum total submission attempts (initial + retries) before giving up.
	 */
	const MAX_SUBMISSION_ATTEMPTS = 3;

	/**
	 * Queue-processor concurrency lock (transient key). Chain hops and cron
	 * ticks can overlap; only one processor may run the retry + bulk passes
	 * at a time. TTL comfortably covers one 60s web budget.
	 */
	const QUEUE_LOCK = 'doig_queue_lock';

	/** Seconds the queue-processor lock survives. */
	const QUEUE_LOCK_TTL = 90;

	/** Chain-hop token lifetime in seconds. Tokens are single-use regardless. */
	const CHAIN_TOKEN_TTL = 60;

	/** Default politeness pause before a chain hop processes (seconds).
	 * Filterable via 'doig_chain_hop_delay' (clamped 0-30). */
	const CHAIN_HOP_DELAY = 2;

	/**
	 * Backoff delays in seconds, indexed by the attempt number that just failed.
	 * Attempt 1 failed → wait 30 s. Attempt 2 failed → wait 120 s. Etc.
	 */
	private static $submission_retry_delays = array( 1 => 30, 2 => 120, 3 => 300 );

	public static function init() {
		// Handler: submit a proxy job for a specific post (individual events)
		add_action( 'doig_take_screenshot', array( __CLASS__, 'handle_take_screenshot' ) );

		// Handler: daily scan for stale screenshots
		add_action( 'doig_daily_refresh_check', array( __CLASS__, 'handle_daily_refresh' ) );

		// Handler: bulk queue processor — retry, bulk, and polling passes
		add_action( 'doig_process_queue', array( __CLASS__, 'handle_process_queue' ) );

		// The chain-hop endpoint (2.6.1): incoming queue-continuation
		// self-requests, validated by single-use token on parse_request -
		// the render engine's door/chain trust model, ported.
		add_action( 'parse_request', array( __CLASS__, 'guard_chain' ), 0 );

		// Handler: cache-flip orphan sweep — 2.4.0 M5. Runs weekly. Marks
		// cached screenshots whose Source mode no longer needs them, and
		// purges anything that has been orphaned for 14+ days.
		add_action( 'doig_orphan_sweep', array( __CLASS__, 'handle_orphan_sweep' ) );

		// 2.4.0 M5: lazily ensure the orphan sweep is scheduled. The
		// register_activation_hook only fires on first-time activation,
		// not on plugin upgrade. Without this lazy schedule, an
		// existing 2.3.x install that updates to 2.4.0 via the WP
		// plugin updater would never schedule doig_orphan_sweep.
		// Running this on every request is cheap - wp_next_scheduled()
		// is an in-memory lookup against the cached cron option. First
		// fire is 7 days out so the schedule never collides with the
		// post-upgrade page load that creates it.
		if ( ! wp_next_scheduled( 'doig_orphan_sweep' ) ) {
			wp_schedule_event( time() + WEEK_IN_SECONDS, 'weekly', 'doig_orphan_sweep' );
		}

		// 2.4.1: dedicated polling pass. Custom 15-minute interval
		// (registered via cron_schedules filter below) so pending
		// screenshot jobs get checked on a predictable schedule even
		// when no bulk work is driving WP-Cron. Decouples polling from
		// the bulk queue processor. Same lazy-schedule pattern as the
		// orphan sweep above so upgrade installs pick it up on the
		// first plugins_loaded after the new code lands.
		add_filter( 'cron_schedules', array( __CLASS__, 'add_cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected
		add_action( 'doig_poll_pending', array( __CLASS__, 'handle_poll_pending' ) );
		if ( ! wp_next_scheduled( 'doig_poll_pending' ) ) {
			wp_schedule_event( time() + ( 15 * MINUTE_IN_SECONDS ), 'doig_15min', 'doig_poll_pending' );
		}

		// Schedule on post publish
		add_action( 'transition_post_status', array( __CLASS__, 'on_post_status_change' ), 10, 3 );

		// Re-schedule when the post's visible content actually changes
		// (post_content / post_title / post_excerpt). post_updated gives
		// us $post_before + $post_after so we can detect a real content
		// diff and skip no-op saves (Update clicked with no changes,
		// sidebar field edits, custom-field tweaks). This hook covers
		// the Block Editor, Classic Editor, WPBakery (shortcodes in
		// post_content), and any builder that updates the wp_posts row.
		add_action( 'post_updated', array( __CLASS__, 'on_post_updated' ), 10, 3 );

		// Page-builder content lives in post meta, not the wp_posts row.
		// Watch updates to a known list of builder content-bearing meta
		// keys so saves through Elementor, BeTheme, Divi, Beaver Builder,
		// Oxygen, Themify, Breakdance, etc. still trigger a regen. The
		// list is filterable via doig_render_affecting_meta_keys for
		// builders we don't yet cover.
		add_action( 'updated_post_meta', array( __CLASS__, 'on_render_meta_updated' ), 10, 4 );
		add_action( 'added_post_meta',   array( __CLASS__, 'on_render_meta_updated' ), 10, 4 );
	}

	// -------------------------------------------------------------------------
	// Scheduling
	// -------------------------------------------------------------------------

	/**
	 * Schedules a screenshot job for a post, preventing duplicates.
	 * Used only for individual post events (publish, save, sidebar regenerate).
	 * Bulk operations use the queue — see force_regenerate_all() and handle_daily_refresh().
	 *
	 * @param int    $post_id
	 * @param int    $delay   Seconds from now. Default 0 (immediate next cron run).
	 * @param string $trigger Capture-trigger provenance sent to the proxy
	 *                        (2.6.2). One of DOIG_Screenshot::CAPTURE_TRIGGERS.
	 *                        Default 'post_update' - schedule() is only called
	 *                        for individual post events (publish, save, lazy
	 *                        first-capture at render), which all trace back to
	 *                        content being created or updated.
	 */
	public static function schedule( $post_id, $delay = 0, $trigger = 'post_update' ) {
		$post_id = absint( $post_id );

		if ( ! $post_id ) {
			return;
		}

		// 2.7.0 M2: surfaces branch to their own quota gate; the
		// post-type and per-post Source gates below assume a real post.
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			if ( ! DOIG_Source_Resolver::surface_needs_screenshot( $post_id ) ) {
				DOIG_Logger::debug(
					sprintf( 'schedule() skipped — surface mode is %s, no capture needed.', DOIG_Source_Resolver::surface_mode( $post_id ) ), array( 'post_id' => $post_id ) );
				return;
			}
		} else {
			// Guard against scheduling screenshots for WordPress-internal
			// singular post types (wp_global_styles, wp_block, wp_template,
			// wp_navigation, etc.). Block themes register these and they
			// can be reached by frontend hooks like the OG injector on
			// wp_head, but the screenshot service hits an HTTP 500 because they
			// are not real renderable pages. See supported_post_types()
			// for the architectural rationale.
			$post_type = get_post_type( $post_id );
			if ( ! in_array( $post_type, self::supported_post_types(), true ) ) {
				DOIG_Logger::debug(
					sprintf( 'schedule() skipped - post type %s is not eligible.', $post_type ?: '(unknown)' ), array( 'post_id' => $post_id ) );
				return;
			}

			// Source-mode quota gate (M4.2). The resolver decides whether this
			// post needs an actual screenshot capture given its Source mode and
			// (for Featured-with-fallback) whether a Featured Image is set.
			// Featured / Custom / Off modes never need a capture, so they
			// must not enter the screenshot pipeline - otherwise the daily
			// refresh, post-update hook, first-publish hook, and Regenerate Now
			// all burn proxy quota for an image the customer doesn't want
			// emitted. needs_screenshot() is the single decision point.
			if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
				DOIG_Logger::debug(
					sprintf( 'schedule() skipped — source mode is %s, no capture needed.', DOIG_Source_Resolver::resolve_mode( $post_id ) ?: 'inherit' ), array( 'post_id' => $post_id ) );
				return;
			}
		}

		// Don't double-schedule
		if ( wp_next_scheduled( 'doig_take_screenshot', array( $post_id ) ) ) {
			DOIG_Logger::debug( 'schedule() skipped — already queued.', array( 'post_id' => $post_id ) );
			return;
		}

		DOIG_Post_Meta::set( $post_id, 'status',          'pending' );
		DOIG_Post_Meta::set( $post_id, 'scheduled_at',    time() );
		DOIG_Post_Meta::set( $post_id, 'capture_trigger', $trigger );

		wp_schedule_single_event( time() + $delay, 'doig_take_screenshot', array( $post_id ) );

		// 2.6.1: fire WP-Cron for the due event right away instead of waiting
		// for the next organic page view - on quiet sites a first-publish
		// capture otherwise waits for the next visitor. spawn_cron() is
		// WP-native, non-blocking, only runs DUE events, and no-ops under
		// DISABLE_WP_CRON, so this is a pure nudge with the old behaviour as
		// the fallback.
		if ( 0 === $delay ) {
			spawn_cron();
		}

		DOIG_Logger::debug(
			$delay > 0
				? sprintf( 'Screenshot queued (fires in %ds).', $delay )
				: 'Screenshot queued.', array( 'post_id' => $post_id ) );
	}

	/**
	 * Cancels any pending individual screenshot job for a post.
	 *
	 * @param int $post_id
	 */
	public static function unschedule( $post_id ) {
		$timestamp = wp_next_scheduled( 'doig_take_screenshot', array( absint( $post_id ) ) );
		if ( $timestamp ) {
			wp_unschedule_event( $timestamp, 'doig_take_screenshot', array( absint( $post_id ) ) );
		}
	}

	/**
	 * Adds or updates a post in the submission retry queue.
	 *
	 * Called by handle_take_screenshot() when a first-time proxy submission fails
	 * transiently. The retry pass in handle_process_queue() handles subsequent attempts
	 * by updating entries directly.
	 *
	 * @param int    $post_id       The post whose submission failed.
	 * @param string $initial_error Human-readable error description for the first attempt.
	 */
	public static function add_submission_retry( $post_id, $initial_error ) {
		$queue   = (array) get_option( self::RETRY_OPTION, array() );
		$post_id = absint( $post_id );

		// Guard: do not add a duplicate entry if the post is already in the queue.
		foreach ( $queue as $entry ) {
			if ( absint( $entry['post_id'] ) === $post_id ) {
				DOIG_Logger::debug( 'add_submission_retry() skipped — post already in retry queue.', array( 'post_id' => $post_id ) );
				return;
			}
		}

		$queue[] = array(
			'post_id'       => $post_id,
			'attempt_count' => 1,
			'next_retry_at' => time() + self::$submission_retry_delays[1],
			'attempts'      => array(
				array(
					'at'    => time(),
					'error' => $initial_error,
				),
			),
		);

		update_option( self::RETRY_OPTION, $queue, false );

		DOIG_Logger::debug(
			sprintf( 'Added to submission retry queue (retry in %ds).', self::$submission_retry_delays[1] ), array( 'post_id' => $post_id ) );
	}

	// -------------------------------------------------------------------------
	// Cron handlers
	// -------------------------------------------------------------------------

	/**
	 * Cron callback: submit an async screenshot job for a given post ID.
	 *
	 * In v2.0, this submits a job to the PlugUpp proxy and returns immediately.
	 * The result arrives later via a callback (class-proxy-callback.php) or
	 * via the polling pass in handle_process_queue().
	 *
	 * On transient proxy failure: adds the post to the submission retry queue.
	 * On success (202 Accepted): schedules a polling guarantee 12 minutes out
	 * so the polling pass runs even if no bulk work triggers handle_process_queue() first.
	 *
	 * @param int $post_id
	 */
	/**
	 * Take a screenshot for one post.
	 *
	 * Wired both as a per-post cron action (single-event submissions for
	 * publish / post_updated / sidebar Regenerate Now) and called directly by
	 * the bulk queue processor in `handle_process_queue()`.
	 *
	 * Returns an outcome string so callers (the bulk processor) can tally
	 * what happened across many posts and emit a useful summary log line.
	 * The string is also helpful when calling this method manually for
	 * debugging. Per-post crons ignore the return value.
	 *
	 * Possible outcomes:
	 *   'invalid_post_id'        - $post_id was zero / non-numeric (no-op).
	 *   'skipped_not_connected'  - plugin not yet connected to the screenshot
	 *                              server (awaiting Freemius opt-in).
	 *   'skipped_locked'         - another concurrent run is mid-submission
	 *                              for this same post.
	 *   'skipped_unpublished'    - post status is not 'publish'.
	 *   'skipped_disabled'       - per-post override OFF, or per-post inherit
	 *                              with global Enable sitewide OFF.
	 *   'submitted'              - 202 Accepted; job is in flight on the proxy.
	 *   'submission_retry'       - transient proxy failure; added to the
	 *                              submission retry queue for another attempt.
	 *   'submission_failed'      - definitive 4xx from the proxy
	 *                              (quota_exceeded, licence_inactive, etc.).
	 *                              submit_proxy_job() already set per-post
	 *                              status and error_code meta.
	 *
	 * @param int $post_id
	 * @return string One of the outcome strings above.
	 */
	public static function handle_take_screenshot( $post_id ) {
		$post_id = absint( $post_id );

		if ( ! $post_id ) {
			return 'invalid_post_id';
		}

		// Plugin must be connected to the screenshot server before any
		// submission. Without an opted-in Freemius site we have no
		// licence_key / site_id and the proxy would reject every request.
		if ( ! DOIG_License::is_connected() ) {
			DOIG_Logger::debug( 'Cron job skipped - plugin not connected (awaiting Freemius activation).', array( 'post_id' => $post_id ) );
			return 'skipped_not_connected';
		}

		// Per-post in-progress lock. WP-Cron is supposed to run events sequentially
		// under a single process, but on sites with a persistent object cache the
		// doing_cron option lock can appear stale to a second PHP process, allowing
		// two cron runners to start concurrently and both pick up the same events.
		// This transient guard ensures only one submission executes per post at a time.
		$lock_key = 'doig_lock_' . $post_id;
		if ( get_transient( $lock_key ) ) {
			DOIG_Logger::debug( 'Cron job skipped — submission already in progress (concurrent cron detected).', array( 'post_id' => $post_id ) );
			return 'skipped_locked';
		}
		// TTL of 5 minutes — well above the proxy submission timeout.
		// Also deleted explicitly after the submission call below.
		set_transient( $lock_key, 1, 5 * MINUTE_IN_SECONDS );

		// No global "is plugin ready" bail here — readiness is per-post. A site
		// with the type-level Source dropdown set to Off may still have
		// specific posts whose per-post Source is set to Auto-screenshot,
		// and those should run. The DOIG_Source_Resolver::needs_screenshot()
		// check below handles both the type default and the per-post
		// override correctly (M4.2).

		// Bail if post is not published
		// 2.7.0 M2: the publish/type/Source gates below assume a real
		// post. Surfaces already passed their own quota gate in
		// schedule(); re-check the mode here (settings may have
		// changed between enqueue and drain) and skip the rest.
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			if ( ! DOIG_Source_Resolver::surface_needs_screenshot( $post_id ) ) {
				DOIG_Post_Meta::set( $post_id, 'status', '' );
				delete_transient( $lock_key );
				DOIG_Logger::debug( 'Aborted — surface no longer needs a capture.', array( 'post_id' => $post_id ) );
				return 'skipped_disabled';
			}
		} else {
			$post_status = get_post_status( $post_id );
			if ( 'publish' !== $post_status ) {
				DOIG_Logger::debug( "Aborted — post status is '{$post_status}', not 'publish'.", array( 'post_id' => $post_id ) );
				delete_transient( $lock_key );
				return 'skipped_unpublished';
			}

			// Belt-and-braces post-type guard. schedule() already filters
			// new submissions but legacy entries in the WP-Cron queue from
			// before this filter existed could still flow through. Refuse
			// them here so a wp_global_styles entry in the queue does not
			// silently retry forever and trigger 500-error alerts.
			$post_type = get_post_type( $post_id );
			if ( ! in_array( $post_type, self::supported_post_types(), true ) ) {
				DOIG_Logger::debug(
					sprintf( 'Aborted — post type %s is not eligible.', $post_type ?: '(unknown)' ), array( 'post_id' => $post_id ) );
				// Clear pending meta so the polling pass does not pick it
				// up next cron tick.
				DOIG_Post_Meta::set( $post_id, 'status', '' );
				delete_transient( $lock_key );
				return 'skipped_unsupported_type';
			}

			// Source-mode quota gate (M4.2). Defence-in-depth: schedule()
			// already gates new submissions, but legacy entries that were
			// added to the bulk queue before the source mode was changed -
			// or that flowed through other paths - can still reach here.
			// needs_screenshot() returns false for Featured / Custom / Off
			// modes and for FwF posts that already have a Featured Image,
			// so any of those slipping through the upstream gates get
			// stopped before the proxy submission.
			if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
				DOIG_Logger::debug(
					sprintf( 'Aborted — source mode is %s, no capture needed.', DOIG_Source_Resolver::resolve_mode( $post_id ) ?: 'inherit' ), array( 'post_id' => $post_id ) );
				delete_transient( $lock_key );
				return 'skipped_disabled';
			}
		}

		// Past all gates. This INFO log used to fire earlier, before the
		// post-status and enabled checks - which made the log misleading
		// when (e.g.) every post in a batch was skipped because Enable
		// sitewide had been turned off. Now it only appears when work is
		// actually about to happen.
		DOIG_Logger::info( 'Cron job started.', array( 'post_id' => $post_id ) );

		// 2.7.0 M2: surfaces resolve their URL via the registry; an empty
		// URL means the surface no longer exists (deleted term, removed
		// archive) - clear state and skip rather than submitting a 404.
		if ( DOIG_Surfaces::is_surface_id( $post_id ) ) {
			$url = DOIG_Surfaces::url_for_key( DOIG_Surfaces::key_for_id( $post_id ) );
		} else {
			$url = get_permalink( $post_id );
		}
		if ( ! $url ) {
			DOIG_Post_Meta::set( $post_id, 'status', '' );
			delete_transient( $lock_key );
			DOIG_Logger::debug( 'Aborted — capture target URL no longer resolves.', array( 'post_id' => $post_id ) );
			return 'skipped_disabled';
		}
		DOIG_Logger::debug( "Submitting screenshot job for: {$url}", array( 'post_id' => $post_id ) );

		// Provenance stamped at enqueue time (schedule() or the bulk
		// queue writers); every queued path drains through here.
		$trigger = DOIG_Post_Meta::get( $post_id, 'capture_trigger' );

		$result = DOIG_Screenshot::submit_proxy_job( $url, $post_id, 1, $trigger );
		delete_transient( $lock_key );

		if ( is_wp_error( $result ) ) {
			if ( 'proxy_transient_error' === $result->get_error_code() ) {
				// Proxy unreachable or 5xx — add to retry queue. submit_proxy_job() already
				// logged the error; DOIG_Cron is responsible for managing the retry queue.
				self::add_submission_retry( $post_id, $result->get_error_message() );

				// Ensure handle_process_queue fires at the retry time.
				$next_event    = wp_next_scheduled( 'doig_process_queue' );
				$next_retry_at = time() + self::$submission_retry_delays[1];
				if ( ! $next_event || $next_event > $next_retry_at ) {
					wp_schedule_single_event( $next_retry_at, 'doig_process_queue' );
				}
				return 'submission_retry';
			}
			// Other error codes (quota_exceeded, licence_inactive, other 4xx) are handled
			// entirely in submit_proxy_job() — status, error_code, and logging are set there.
			return 'submission_failed';
		}

		// 202 Accepted — job submitted. Schedule a polling guarantee just past the
		// 10-minute callback threshold so the polling pass runs even if no bulk work
		// triggers handle_process_queue() within the next 12 minutes.
		// Guard: only schedule if there is no existing event within the next 12 minutes.
		$next_queue = wp_next_scheduled( 'doig_process_queue' );
		if ( ! $next_queue || $next_queue > time() + self::POLL_GUARANTEE_DELAY ) {
			wp_schedule_single_event( time() + self::POLL_GUARANTEE_DELAY, 'doig_process_queue' );
			DOIG_Logger::debug( 'Polling guarantee scheduled (fires in 12 min).', array( 'post_id' => $post_id ) );
		}

		return 'submitted';
	}

	/**
	 * Cron callback: process posts from the bulk regeneration queue.
	 *
	 * Runs three passes in sequence on every execution:
	 *   1. Submission retry pass — retries failed proxy job submissions.
	 *   2. Bulk screenshot pass  — submits proxy jobs for queued posts (time-boxed).
	 *   3. Polling pass          — checks pending jobs older than 10 minutes.
	 *
	 * Processes posts in a loop until either the queue is empty or the wall-clock
	 * time budget is exhausted, then stops and lets the next cron run continue.
	 * Always processes at least one post per execution regardless of budget.
	 *
	 * The continuation event is scheduled at the top, before the loop starts.
	 * If the process is killed mid-run by a server timeout, the continuation fires
	 * at the next cron opportunity and picks up from where the queue left off.
	 * If the queue drains normally, the continuation fires and exits immediately
	 * on the empty-queue check.
	 *
	 * The time budget can be adjusted via the doig_queue_time_limit filter (seconds).
	 */
	public static function handle_process_queue() {

		// Bail when the plugin is not connected to the screenshot server.
		// All three queue passes (retry, polling, status) require a working
		// licence_key + site_id; without them every submission would 422
		// and every poll would 401. Leaves queued work in place so it
		// resumes automatically once setup completes.
		if ( ! DOIG_License::is_connected() ) {
			DOIG_Logger::debug( 'Process-queue cron skipped - plugin not connected (awaiting Freemius activation).' );
			return;
		}

		// Heartbeat for the Dashboard backlog notice (2.6.2): the Dashboard
		// warns when the queue holds work but this processor has not run
		// for 30+ minutes - the "WP-Cron is stalled" condition that was
		// previously visible only in the log file.
		update_option( 'doig_queue_last_run_at', time(), false );

		// Watchdog re-arm BEFORE the lock check (2.6.1). When WP-Cron fires
		// this hook, core removes the event from the cron array before the
		// handler runs - so a tick that then finds the lock held would
		// consume the event with nothing rescheduling it, stranding a
		// running queue (the render engine's locked-tick leak, fixed there
		// at 0.3.1; built leak-proof here from day one). 2.7.1: the retry
		// queue counts too - a retry-only queue under a held lock could
		// otherwise strand until the 12-min poll guarantee (Pass 1's own
		// re-schedule runs inside the lock, which this tick never reaches).
		$has_pending_work = get_option( self::QUEUE_OPTION, array() ) || get_option( self::RETRY_OPTION, array() );
		if ( $has_pending_work && ! wp_next_scheduled( 'doig_process_queue' ) ) {
			wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'doig_process_queue' );
		}

		// Processor lock (2.6.1): chain hops and cron ticks can genuinely
		// overlap; only one runner may work the retry + bulk passes at a
		// time. The lock holder dispatches the next hop at its own end.
		if ( false !== get_transient( self::QUEUE_LOCK ) ) {
			DOIG_Logger::debug( 'Process-queue skipped - another runner holds the queue lock.' );
			return;
		}
		set_transient( self::QUEUE_LOCK, 1, self::QUEUE_LOCK_TTL );

		$dispatch_next_hop = false;

		// -------------------------------------------------------------------------
		// Pass 1: Submission retry queue
		// -------------------------------------------------------------------------

		$retry_queue = (array) get_option( self::RETRY_OPTION, array() );

		if ( ! empty( $retry_queue ) ) {
			$now                 = time();
			$retry_queue_changed = false;

			foreach ( $retry_queue as $idx => &$entry ) {
				if ( $entry['next_retry_at'] > $now ) {
					continue; // Not yet due — leave for a future run.
				}

				$post_id       = absint( $entry['post_id'] );
				$attempt_count = (int) $entry['attempt_count'];
				$attempt_num   = $attempt_count + 1; // This is the attempt number (1-based)

				// Source-mode quota gate (M4.2). The retry entry was
				// queued at submission time. Between then and now the
				// customer may have switched this post's Source mode
				// (Settings dropdown, sidebar SelectControl, or a bulk
				// action) so the queued retry is no longer wanted.
				// Drop the entry silently rather than burn another
				// submission attempt for a post that won't emit a
				// screenshot anyway.
				if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
					DOIG_Logger::debug(
						sprintf( 'Submission retry dropped — source mode flipped to %s since queue add.', DOIG_Source_Resolver::resolve_mode( $post_id ) ?: 'inherit' ), array( 'post_id' => $post_id ) );
					unset( $retry_queue[ $idx ] );
					$retry_queue_changed = true;
					continue;
				}

				$url    = get_permalink( $post_id );
				$result = DOIG_Screenshot::submit_proxy_job( $url, $post_id, $attempt_num, 'retry' );

				$retry_queue_changed = true;

				if ( true === $result ) {
					// 202 Accepted — job submitted successfully. Remove from retry queue.
					DOIG_Logger::debug( 'Submission retry succeeded — removed from retry queue.', array( 'post_id' => $post_id ) );
					unset( $retry_queue[ $idx ] );
					continue;
				}

				$error_code = $result->get_error_code();

				if ( 'proxy_transient_error' === $error_code ) {
					// Record this attempt's outcome in the entry.
					$entry['attempts'][] = array(
						'at'    => $now,
						'error' => $result->get_error_message(),
					);

					if ( $attempt_num >= self::MAX_SUBMISSION_ATTEMPTS ) {
						// All attempts exhausted — mark as failed.
						$history = '';
						foreach ( $entry['attempts'] as $i => $attempt ) {
							$history .= sprintf(
								' Attempt %d (%s): %s.',
								$i + 1,
								wp_date( 'H:i', $attempt['at'] ),
								$attempt['error']
							);
						}

						DOIG_Logger::error(
							sprintf(
								'Post %d — screenshot server unreachable after %d submission attempts.%s Giving up.',
								$post_id,
								self::MAX_SUBMISSION_ATTEMPTS,
								$history
							)
						);

						DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
						DOIG_Post_Meta::set( $post_id, 'error_code',    'proxy_unavailable' );
						DOIG_Post_Meta::set( $post_id, 'error_message', trim( $history ) );

						unset( $retry_queue[ $idx ] );
					} else {
						// More attempts remaining — schedule the next retry.
						$entry['attempt_count'] = $attempt_num;
						$delay                  = isset( self::$submission_retry_delays[ $attempt_num ] )
							? self::$submission_retry_delays[ $attempt_num ]
							: 300;
						$entry['next_retry_at'] = $now + $delay;

						DOIG_Logger::debug(
							sprintf( 'Submission retry %d failed — will retry in %ds.', $attempt_num, $delay ), array( 'post_id' => $post_id ) );
					}
				} else {
					// Definitive error (quota_exceeded, licence_inactive, or other 4xx).
					// submit_proxy_job() already set the appropriate status/error_code.
					// Remove from the retry queue — there is no point retrying definitive failures.
					DOIG_Logger::debug(
						sprintf( 'Submission retry — definitive error (%s), removed from retry queue.', $error_code ), array( 'post_id' => $post_id ) );
					unset( $retry_queue[ $idx ] );
				}
			}
			unset( $entry );

			if ( $retry_queue_changed ) {
				$retry_queue = array_values( $retry_queue );
				update_option( self::RETRY_OPTION, $retry_queue, false );
			}

			// Schedule the next process_queue run for the earliest remaining retry,
			// if it is sooner than any already-scheduled event.
			if ( ! empty( $retry_queue ) ) {
				$next_retry_timestamps = array_column( $retry_queue, 'next_retry_at' );
				$earliest              = min( $next_retry_timestamps );
				$next_event            = wp_next_scheduled( 'doig_process_queue' );
				if ( ! $next_event || $next_event > $earliest ) {
					wp_schedule_single_event( $earliest, 'doig_process_queue' );
				}
			}
		}

		// -------------------------------------------------------------------------
		// Pass 2: Bulk screenshot queue
		// -------------------------------------------------------------------------

		$queue = get_option( self::QUEUE_OPTION, array() );

		if ( ! empty( $queue ) ) {

			// Schedule the continuation event before we start work.
			// This ensures the queue always advances even if this execution is killed
			// by a server-level timeout before the loop completes.
			if ( ! wp_next_scheduled( 'doig_process_queue' ) ) {
				wp_schedule_single_event( time(), 'doig_process_queue' );
			}

			/**
			 * Wall-clock time budget for one queue processor execution, in seconds.
			 * The loop stops after this many seconds have elapsed, leaving remaining
			 * posts for the continuation event at the next cron run.
			 *
			 * The default budget is determined by the PHP SAPI:
			 *
			 *   CLI (php wp-cron.php called directly by server cron):
			 *     No web server or FPM timeout applies — use a very large value so the
			 *     entire queue drains in one execution.
			 *
			 *   Web SAPI (fpm-fcgi, apache2handler, etc. — standard WP-Cron loopback
			 *   or server cron calling wp-cron.php via wget/curl):
			 *     PHP-FPM's request_terminate_timeout is the real ceiling, but it is a
			 *     pool-manager setting invisible to PHP code — there is no API to read it.
			 *     60 seconds is a conservative default: it yields 3-4 posts at typical
			 *     proxy submission response times while staying well inside the 300-second
			 *     default that most hosts use when they configure this at all.
			 *
			 * Override with the doig_queue_time_limit filter when you know your hosting
			 * environment supports a higher value (e.g. add_filter('doig_queue_time_limit',
			 * fn() => 240) on a VPS where you control PHP-FPM config).
			 *
			 * @param int $time_limit Seconds.
			 */
			$default_limit    = ( 'cli' === php_sapi_name() ) ? PHP_INT_MAX : 60;
			$time_limit       = max( 10, (int) apply_filters( 'doig_queue_time_limit', $default_limit ) );
			$start_time       = microtime( true );
			$processed        = 0;
			$stopped_on_limit = false;
			$tally            = array(
				'submitted'             => 0,
				'submission_retry'      => 0,
				'submission_failed'     => 0,
				'skipped_disabled'      => 0,
				'skipped_unpublished'   => 0,
				'skipped_locked'        => 0,
				'skipped_not_connected' => 0,
				'invalid_post_id'       => 0,
			);

			while ( ! empty( $queue ) ) {
				$post_id = absint( array_shift( $queue ) );

				// Persist the remaining queue before the submission call. If the process
				// is killed mid-call, the queue survives and the continuation picks it up.
				update_option( self::QUEUE_OPTION, array_values( $queue ), false );

				$processed++;

				DOIG_Logger::debug(
					sprintf( 'Queue run: processing post (item %d, %d remaining).', $processed, count( $queue ) ), array( 'post_id' => $post_id ) );

				$outcome = self::handle_take_screenshot( $post_id );
				if ( isset( $tally[ $outcome ] ) ) {
					$tally[ $outcome ]++;
				}

				// Check wall-clock elapsed time after each post.
				// Always process at least one post regardless of elapsed time, so a
				// single slow submission never stalls the queue permanently.
				if ( $processed >= 1 && ( microtime( true ) - $start_time ) >= $time_limit ) {
					$stopped_on_limit = true;
					break;
				}
			}

			// Chain the next batch (2.6.1): only a budget stop with work
			// actually done qualifies - never a lock-blocked or drained run.
			// The dispatch itself happens AFTER the lock is released below.
			if ( ! empty( $queue ) && $processed > 0 && $stopped_on_limit ) {
				$dispatch_next_hop = true;
			}

			$elapsed = round( microtime( true ) - $start_time, 1 );
			$summary = self::format_queue_outcome_summary( $tally );

			if ( empty( $queue ) ) {
				DOIG_Logger::info(
					sprintf(
						'Queue complete — %d post(s) examined in %.1fs. %s',
						$processed,
						$elapsed,
						$summary
					)
				);
			} else {
				DOIG_Logger::info(
					sprintf(
						'Queue run complete — %d post(s) examined in %.1fs, %d remaining (continuing at next cron run). %s',
						$processed,
						$elapsed,
						count( $queue ),
						$summary
					)
				);
			}
		}

		// -------------------------------------------------------------------------
		// Pass 3: Callback polling fallback. Body extracted into
		// poll_pending_jobs() in 2.4.1 so the dedicated doig_poll_pending
		// cron handler can call the same code path without re-running the
		// retry queue + bulk passes above.
		// -------------------------------------------------------------------------
		self::poll_pending_jobs();

		// Release the processor lock BEFORE dispatching the next hop - the
		// order is load-bearing: a hop arriving while the lock is still held
		// would return without working and the chain would die (2.6.1).
		delete_transient( self::QUEUE_LOCK );

		if ( $dispatch_next_hop ) {
			self::dispatch_chain();
		}
	}

	// -------------------------------------------------------------------------
	// Loopback chain (2.6.1) - hop endpoint + tokens + dispatch
	// -------------------------------------------------------------------------

	/**
	 * Validate and run chain-hop requests. Requests WITHOUT the chain query
	 * var are untouched. Requests with it must carry a valid single-use token
	 * minted by dispatch_chain(), or the request dies with 403 - the endpoint
	 * never leaks anything on bad input. The hop response body is irrelevant;
	 * the dispatcher fires it non-blocking and never reads it.
	 */
	public static function guard_chain() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the single-use token IS the credential; this is not a form action.
		if ( ! isset( $_GET['doig_chain'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$name = sanitize_key( wp_unslash( $_GET['doig_chain'] ) );
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$token = isset( $_GET['doig_chain_token'] ) ? sanitize_text_field( wp_unslash( $_GET['doig_chain_token'] ) ) : '';

		if ( 'bulk' !== $name || ! self::consume_chain_token( $token ) ) {
			DOIG_Logger::info( 'Chain hop rejected (invalid or reused token).' );
			wp_die(
				esc_html__( 'Invalid chain request.', 'dynamic-og-image-generator' ),
				'',
				array( 'response' => 403 )
			);
		}

		// Politeness pause in a dedicated non-blocking request - keeps the
		// submission duty cycle polite on small shared hosts.
		$delay = (int) apply_filters( 'doig_chain_hop_delay', self::CHAIN_HOP_DELAY );
		$delay = max( 0, min( 30, $delay ) );
		if ( $delay > 0 ) {
			sleep( $delay );
		}

		// The processor self-dispatches the next hop when its bulk pass
		// stops on budget with work remaining.
		self::handle_process_queue();
		exit;
	}

	/**
	 * Fire the next chain hop: a non-blocking loopback self-request carrying
	 * a fresh single-use token. Failure is silent BY DESIGN - the WP-Cron
	 * continuation (re-armed on every processor entry) restores exactly the
	 * pre-chain cadence, so a host that blocks rapid self-requests degrades
	 * to today's behaviour, never worse.
	 */
	private static function dispatch_chain() {
		/**
		 * Kill switch: disable the loopback chain (reverts to the cron-only
		 * continuation model).
		 *
		 * @param bool $enabled Default true.
		 */
		if ( ! apply_filters( 'doig_chain_enabled', true ) ) {
			return;
		}

		$token = bin2hex( random_bytes( 16 ) );
		set_transient( 'doig_ct_' . $token, 'bulk', self::CHAIN_TOKEN_TTL );

		$url = add_query_arg(
			array(
				'doig_chain'       => 'bulk',
				'doig_chain_token' => $token,
			),
			home_url( '/' )
		);

		/**
		 * Dispatch timeout for the non-blocking hop request. Some transports
		 * need >= 1s for a non-blocking request to actually leave the box;
		 * raise via this filter if hops never fire on a host.
		 *
		 * @param float $timeout Seconds. Default 0.01.
		 */
		$timeout = (float) apply_filters( 'doig_chain_dispatch_timeout', 0.01 );

		wp_remote_post( $url, array(
			'blocking'  => false,
			'timeout'   => max( 0.01, $timeout ),
			'sslverify' => false, // Self-request.
		) );

		DOIG_Logger::debug( 'Chain hop dispatched for the bulk queue.' );
	}

	/**
	 * Validate and consume (delete) a chain token. Valid only when it exists,
	 * has not expired, and was minted for the bulk queue.
	 *
	 * @param string $token Presented token.
	 * @return bool
	 */
	private static function consume_chain_token( $token ) {
		if ( '' === $token || ! preg_match( '/^[a-f0-9]{32}$/', $token ) ) {
			return false;
		}

		$stored = get_transient( 'doig_ct_' . $token );
		delete_transient( 'doig_ct_' . $token ); // Single use, even on mismatch.

		return 'bulk' === $stored;
	}

	/**
	 * Callback polling fallback — query posts that are still 'pending'
	 * with a job_id but have been waiting longer than POLL_THRESHOLD
	 * seconds, and poll the proxy for each.
	 *
	 * Extracted from handle_process_queue() Pass 3 in 2.4.1 so both
	 * entry points (the existing inline call inside the bulk processor
	 * AND the new dedicated `doig_poll_pending` cron schedule) share
	 * the same logic. The work is idempotent — calling it on a job that
	 * has already resolved is a no-op (the GET to the proxy returns the
	 * same status, the meta is already updated). Safe to invoke from any
	 * context.
	 *
	 * Cheap: bounded meta query (only posts mid-render with an unanswered
	 * proxy job), single GET per pending post. Runs in milliseconds on
	 * sites with single-digit pending counts.
	 */
	public static function poll_pending_jobs() {
		$poll_cutoff = time() - self::POLL_THRESHOLD;

		$poll_query = new WP_Query( array(
			'post_type'      => 'any',
			'post_status'    => 'any',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
			// Pending-job lookup. The set is small (only posts mid-render)
			// so the meta_query is bounded and cheap regardless of total
			// post count.
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			'meta_query'     => array(
				'relation' => 'AND',
				array(
					'key'   => '_doig_status',
					'value' => 'pending',
				),
				// job_id guard: posts in the submission retry queue are also pending
				// but have no job_id and must not be polled.
				array(
					'key'     => '_doig_job_id',
					'value'   => '',
					'compare' => '!=',
				),
				array(
					'key'     => '_doig_scheduled_at',
					'value'   => $poll_cutoff,
					'compare' => '<',
					'type'    => 'NUMERIC',
				),
			),
		) );

		// 2.7.0 M2: surface jobs live outside postmeta, so the WP_Query
		// above cannot see them - scan the surface stores too. Without
		// this, a surface capture on a callback-blocked host would stay
		// pending forever.
		$surface_ids = DOIG_Surfaces::pending_surface_ids( $poll_cutoff );

		if ( empty( $poll_query->posts ) && empty( $surface_ids ) ) {
			return;
		}

		$licence_key = DOIG_Screenshot::get_licence_key();
		foreach ( array_merge( $poll_query->posts, $surface_ids ) as $post_id ) {
			self::poll_proxy_job( $post_id, $licence_key );
		}
	}

	/**
	 * Register the custom 15-minute WP-Cron interval used by the
	 * dedicated polling event (2.4.1).
	 *
	 * Hooked on `cron_schedules` from init() so the interval is
	 * available whenever WP-Cron checks event schedules (registration,
	 * lazy reschedule, deactivation cleanup, manual `wp cron event run`).
	 *
	 * The Plugin Check `CronInterval` rule flags any interval below
	 * 15 minutes. Ours is exactly 15 minutes — at the threshold — so
	 * the rule should accept it; the `phpcs:ignore` on the add_filter()
	 * line in init() pre-empts any false positive from a stricter
	 * configuration of the sniff (matching the pattern used by other
	 * established plugins like Yoast).
	 *
	 * @param array $schedules Existing schedules keyed by name.
	 * @return array
	 */
	public static function add_cron_schedules( $schedules ) {
		if ( ! isset( $schedules['doig_15min'] ) ) {
			$schedules['doig_15min'] = array(
				'interval' => 15 * MINUTE_IN_SECONDS,
				// Deliberately NOT translated. The `cron_schedules` filter
				// fires whenever wp_get_schedules() runs - including from
				// our own lazy wp_schedule_event() in init() (on
				// plugins_loaded) and from core/other plugins BEFORE the
				// `init` hook. A __() here would therefore be a pre-init
				// translation call and trip WP 6.7's
				// _load_textdomain_just_in_time notice on sites with a
				// language pack. This 'display' is only surfaced by
				// cron-viewer tools (e.g. WP Crontrol), so a plain string
				// is the right trade-off. See the WP_DEBUG gate.
				'display'  => 'Every 15 Minutes (DOIG polling)',
			);
		}
		return $schedules;
	}

	/**
	 * Dedicated 15-minute polling cron handler (2.4.1).
	 *
	 * Hooked on the recurring `doig_poll_pending` event. Decouples the
	 * polling pass from the bulk queue processor so pending jobs get
	 * checked on a predictable schedule even when no bulk work is
	 * driving WP-Cron - and without relying on the 12-minute single-
	 * post polling-guarantee trick that pre-2.4.1 single-submission
	 * code paths used to ensure callbacks are not lost.
	 *
	 * Connection gate: skip silently when the plugin is not connected.
	 * No proxy credentials, no point polling.
	 */
	public static function handle_poll_pending() {
		if ( ! DOIG_License::is_connected() ) {
			return;
		}
		self::poll_pending_jobs();
	}

	/**
	 * Polls the screenshot server for the status of a pending job and processes
	 * the result.
	 *
	 * Public so the Gutenberg sidebar's status endpoint can drive an
	 * interactive poll without waiting for the next cron pass. Idempotent:
	 * calling it on a job that is already done is a no-op (the GET to the
	 * server returns the same status, the meta is already updated). Called
	 * by the polling pass in handle_process_queue() for any pending post
	 * whose job has been waiting longer than POLL_THRESHOLD seconds without
	 * a callback.
	 *
	 * @param int    $post_id     The post to poll.
	 * @param string $licence_key Freemius licence key (passed in to avoid repeated calls).
	 */
	public static function poll_proxy_job( $post_id, $licence_key ) {
		$job_id = DOIG_Post_Meta::get( $post_id, 'job_id' );

		if ( empty( $job_id ) ) {
			DOIG_Logger::debug( 'Polling pass skipped — no job_id.', array( 'post_id' => $post_id ) );
			return;
		}

		DOIG_Logger::debug(
			sprintf( 'Polling screenshot server for job %s.', $job_id ), array( 'post_id' => $post_id ) );

		$response = wp_remote_get(
			DOIG_License::proxy_base_url() . '/v1/job/' . rawurlencode( $job_id ),
			array(
				'timeout' => 15,
				'headers' => array(
					'Authorization' => 'Bearer ' . $licence_key,
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			DOIG_Logger::info(
				sprintf(
					'Post %d — poll request failed (WP_Error %s): %s. Will retry on next cron run.',
					$post_id,
					$response->get_error_code(),
					$response->get_error_message()
				)
			);
			return;
		}

		$http_code = (int) wp_remote_retrieve_response_code( $response );
		$body_raw  = wp_remote_retrieve_body( $response );

		if ( 200 !== $http_code ) {
			DOIG_Logger::info(
				sprintf(
					'Post %d — poll request returned HTTP %d: %s. Will retry on next cron run.',
					$post_id,
					$http_code,
					$body_raw ? substr( $body_raw, 0, 200 ) : '(empty body)'
				)
			);
			return;
		}

		$body = json_decode( $body_raw, true );
		$status = isset( $body['status'] ) ? (string) $body['status'] : '';

		// For terminal states, atomically claim the job before processing.
		// If the async callback path arrived at /doig/v1/callback at the same
		// time, only one of us will win the SQL compare-and-clear; the loser
		// returns silently without firing a duplicate download or
		// attachment-creation. See DOIG_Post_Meta::claim_job() for details.
		$is_terminal = in_array( $status, array( 'completed', 'failed', 'quota_exceeded' ), true );
		if ( $is_terminal && ! DOIG_Post_Meta::claim_job( $post_id, $job_id ) ) {
			DOIG_Logger::debug(
				sprintf(
					'Post %d — poll observed terminal status %s for job %s, but claim was lost to the async callback path. Skipping duplicate processing.',
					$post_id,
					$status,
					$job_id
				)
			);
			return;
		}

		// Callback-blocked detection (2.6.2): the poll path winning a
		// terminal claim means the proxy finished the job but its callback
		// never landed here. One old job could be a blip; a job 15+
		// minutes past schedule resolved by polling is the documented
		// LIMITATIONS.md §3 signature of a host blocking the callback
		// route. Record it for the Dashboard notice and say so in the log.
		if ( $is_terminal ) {
			$scheduled_at = (int) DOIG_Post_Meta::get( $post_id, 'scheduled_at' );
			if ( $scheduled_at > 0 && ( time() - $scheduled_at ) >= 15 * MINUTE_IN_SECONDS ) {
				update_option( 'doig_callback_blocked_at', time(), false );
				DOIG_Logger::warning(
					sprintf(
						'Post %d - capture resolved by POLLING %d minutes after scheduling. The proxy callback appears to be blocked (firewall/security rule on doig/v1/callback?). Captures still work but are slower.',
						$post_id,
						(int) round( ( time() - $scheduled_at ) / 60 )
					),
					array( 'post_id' => $post_id )
				);
			}
		}

		if ( 'completed' === $status ) {
			$image_url = isset( $body['image_url'] ) ? (string) $body['image_url'] : '';

			if ( empty( $image_url ) ) {
				DOIG_Logger::error(
					sprintf( 'Post %d — poll returned completed but no image_url.', $post_id )
				);
				return;
			}

			$file_path = DOIG_Screenshot::save_proxy_image( $image_url, $post_id );

			if ( is_wp_error( $file_path ) ) {
				DOIG_Logger::error(
					sprintf(
						'Post %d — poll: image download failed — %s',
						$post_id,
						$file_path->get_error_message()
					)
				);
				return;
			}

			$retry_count = isset( $body['retry_count'] ) ? (int) $body['retry_count'] : 0;
			$now         = time();
			$cached_url  = DOIG_Cache::get_file_url( $post_id ) . '?v=' . $now;

			DOIG_Post_Meta::set( $post_id, 'status',        'ready' );
			DOIG_Post_Meta::set( $post_id, 'cached_url',    $cached_url );
			DOIG_Post_Meta::set( $post_id, 'cached_at',     $now );
			// job_id was already cleared by claim_job() above.
			DOIG_Post_Meta::set( $post_id, 'error_code',    '' );
			DOIG_Post_Meta::set( $post_id, 'error_message', '' );

			DOIG_Logger::info(
				sprintf(
					'Post %d — screenshot ready via poll (job completed after %d %s).',
					$post_id,
					$retry_count,
					1 === $retry_count ? 'retry' : 'retries'
				)
			);

		} elseif ( 'failed' === $status ) {
			$error_code    = isset( $body['error_code'] ) ? sanitize_key( (string) $body['error_code'] ) : 'unknown';
			$error_message = isset( $body['error_message'] ) ? (string) $body['error_message'] : '';
			$retry_count   = isset( $body['retry_count'] ) ? (int) $body['retry_count'] : 0;

			DOIG_Post_Meta::set( $post_id, 'status',        'failed' );
			DOIG_Post_Meta::set( $post_id, 'error_code',    $error_code );
			DOIG_Post_Meta::set( $post_id, 'error_message', $error_message );
			// job_id was already cleared by claim_job() above.

			DOIG_Logger::error(
				sprintf(
					'Post %d — screenshot failed via poll (%d %s): %s — %s',
					$post_id,
					$retry_count,
					1 === $retry_count ? 'retry' : 'retries',
					$error_code,
					$error_message
				)
			);

		} elseif ( 'quota_exceeded' === $status ) {
			DOIG_Post_Meta::set( $post_id, 'status',  'quota_exceeded' );
			// job_id was already cleared by claim_job() above.

			DOIG_Logger::info(
				sprintf( 'Post %d — monthly screenshot quota exceeded (poll). Will resume when quota resets.', $post_id )
			);

		} elseif ( 'queued' === $status || 'processing' === $status ) {
			DOIG_Logger::debug(
				sprintf( 'Post %d — job still %s (poll). Leaving as pending.', $post_id, $status ), array( 'post_id' => $post_id ) );

		} else {
			DOIG_Logger::info(
				sprintf( 'Post %d — poll returned unrecognised status: %s', $post_id, $status )
			);
		}
	}

	/**
	 * Cron callback: scan all enabled posts and queue stale or quota_exceeded ones for refresh.
	 *
	 * Stale posts (cache past refresh_interval) are queued for a fresh screenshot.
	 * quota_exceeded posts are also requeued so they are captured once the proxy starts
	 * accepting jobs again after the billing period resets.
	 */
	public static function handle_daily_refresh() {
		// No global "is plugin ready" bail — per-post overrides may still
		// require the daily refresh to run for individual posts even when
		// the global Enabled toggle is OFF. The per-post is_enabled() check
		// inside the loop below handles both the global setting and the
		// per-post override correctly.
		//
		// BUT: if the plugin is not connected to the screenshot server,
		// every queued submission would 422. Skip the whole pass and try
		// again tomorrow.
		if ( ! DOIG_License::is_connected() ) {
			DOIG_Logger::debug( 'Daily refresh skipped - plugin not connected (awaiting Freemius activation).' );
			return;
		}

		DOIG_Logger::info( 'Daily refresh check started.' );

		$query = new WP_Query( array(
			'post_type'      => self::supported_post_types(),
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
			// Daily refresh: runs once per day on a single hook. The two-arm
			// meta_query filters out posts that the resolver would definitely
			// reject anyway:
			//   - Legacy arm: pre-2.4.0 force-off (_doig_enabled = '0').
			//   - 2.4.0 arm: per-post Source explicitly Featured / Custom /
			//     Off. Those modes never need a screenshot regardless of
			//     content-type default, so excluding them at SQL avoids
			//     loading rows the M4.2 PHP gate would only reject. Posts
			//     where _doig_source is unset (Inherit) flow through and
			//     resolve per-row in PHP via the resolver's type_default().
			// Tightened 2.4.1 - previously the SQL filter only covered the
			// legacy arm.
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			'meta_query'     => array(
				'relation' => 'AND',
				array(
					'relation' => 'OR',
					array(
						'key'     => '_doig_enabled',
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'     => '_doig_enabled',
						'value'   => '0',
						'compare' => '!=',
					),
				),
				array(
					'relation' => 'OR',
					array(
						'key'     => '_doig_source',
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'     => '_doig_source',
						'value'   => array( 'featured', 'custom', 'off' ),
						'compare' => 'NOT IN',
					),
				),
			),
		) );

		if ( empty( $query->posts ) ) {
			DOIG_Logger::info( 'Daily refresh — no eligible posts found.' );
			return;
		}

		// Per-post-type defaults: the daily refresh query returns posts and pages
		// (and any Premium-enabled CPTs) mixed together, so each iteration
		// resolves the type-appropriate default via the helper rather than
		// passing a single boolean for all rows.
		$to_queue = array();

		foreach ( $query->posts as $post_id ) {
			// Source-mode quota gate (M4.2). Replaces the pre-2.4.0
			// is_enabled() check. The resolver is the single decision
			// point for "does this post need a screenshot capture?" -
			// Featured / Custom / Off modes get skipped here so the
			// daily refresh never queues quota-burning work for posts
			// that won't emit a screenshot anyway. Equivalent for
			// is_enabled() on screenshot-mode posts (because the M2
			// migration derives enabled_X from source_X).
			if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
				continue;
			}

			$post_status = DOIG_Post_Meta::get( $post_id, 'status' );

			// Requeue quota_exceeded posts — these are pending work waiting for quota
			// to reset. The proxy will accept them again on the next billing period.
			if ( 'quota_exceeded' === $post_status ) {
				DOIG_Post_Meta::set( $post_id, 'status',       'pending' );
				DOIG_Post_Meta::set( $post_id, 'scheduled_at', time() );
				$to_queue[] = $post_id;
				DOIG_Logger::debug( 'Requeued — quota_exceeded (billing period may have reset).', array( 'post_id' => $post_id ) );
				continue;
			}

			// Skip posts whose previous job is still in flight. The polling
			// guarantee, callback, or submission retry queue will resolve
			// them. Re-queuing here causes the same post to be screenshotted
			// twice — once via the in-flight job, once via the bulk queue —
			// burning quota for no benefit.
			if ( 'pending' === $post_status ) {
				DOIG_Logger::debug( 'Skipped — already pending (in flight).', array( 'post_id' => $post_id ) );
				continue;
			}

			// Skip if still fresh
			if ( DOIG_Cache::is_fresh( $post_id ) ) {
				DOIG_Logger::debug( 'Skipped — cache still fresh.', array( 'post_id' => $post_id ) );
				continue;
			}

			// Mark as pending so the admin list reflects the queued state.
			DOIG_Post_Meta::set( $post_id, 'status',          'pending' );
			DOIG_Post_Meta::set( $post_id, 'scheduled_at',    time() );
			DOIG_Post_Meta::set( $post_id, 'capture_trigger', 'scheduled_refresh' );

			$to_queue[] = $post_id;
		}

		// 2.7.0 M5: enabled Screenshot-mode surfaces join the sweep with
		// the same skip logic - in-flight and still-fresh surfaces stay
		// out. enabled_surface_ids() already applies the consent chain
		// (group enabled, premium gating, resolvable URL).
		foreach ( DOIG_Surfaces::enabled_surface_ids() as $surface_id ) {
			$status = (string) DOIG_Post_Meta::get( $surface_id, 'status' );
			if ( 'pending' === $status ) {
				DOIG_Logger::debug( 'Skipped — already pending (in flight).', array( 'post_id' => $surface_id ) );
				continue;
			}
			if ( DOIG_Cache::is_fresh( $surface_id ) ) {
				DOIG_Logger::debug( 'Skipped — cache still fresh.', array( 'post_id' => $surface_id ) );
				continue;
			}
			DOIG_Post_Meta::set( $surface_id, 'status',          'pending' );
			DOIG_Post_Meta::set( $surface_id, 'scheduled_at',    time() );
			DOIG_Post_Meta::set( $surface_id, 'capture_trigger', 'scheduled_refresh' );
			$to_queue[] = $surface_id;
		}

		if ( empty( $to_queue ) ) {
			DOIG_Logger::info( 'Daily refresh complete — all caches are fresh, nothing queued.' );
			return;
		}

		// Merge with any existing queue from a previous incomplete run, deduplicated.
		$existing_queue = (array) get_option( self::QUEUE_OPTION, array() );
		$merged_queue   = array_values( array_unique( array_merge( $existing_queue, $to_queue ) ) );
		update_option( self::QUEUE_OPTION, $merged_queue, false );

		// Force-schedule the queue processor for immediate run. Any
		// existing future event - typically a 12-minute polling guarantee
		// from a manual Regenerate Now - gets replaced. The polling work
		// happens inside the same processor, so it still runs, just sooner.
		// Previously this was a noop-if-already-scheduled guard, which
		// caused bulk work to wait up to 12 minutes when a polling event
		// happened to be on the calendar from a recent manual regen.
		$existing = wp_next_scheduled( 'doig_process_queue' );
		if ( $existing ) {
			wp_unschedule_event( $existing, 'doig_process_queue' );
		}
		wp_schedule_single_event( time(), 'doig_process_queue' );

		// Chain kick-off (2.6.1): start submitting immediately instead of
		// waiting for the next cron opportunity.
		self::dispatch_chain();

		DOIG_Logger::info(
			'Daily refresh complete — ' . count( $to_queue ) . ' post(s) added to queue from ' . count( $query->posts ) . ' checked.'
		);
	}

	/**
	 * Forces a screenshot regeneration for every eligible post, regardless of
	 * whether the cache is still fresh. Called by the "Regenerate All" admin button.
	 *
	 * Unlike handle_daily_refresh(), this ignores the refresh interval and always
	 * queues every enabled, published post.
	 *
	 * Also removes any doig_submission_retry entries for posts being re-queued —
	 * the fresh bulk job takes precedence over a pending retry attempt.
	 *
	 * Uses the bulk queue rather than individual cron events so each cron execution
	 * makes at most one proxy submission — safe on all hosting environments including
	 * shared hosting with strict wall-clock process timeouts.
	 *
	 * @return int Number of posts queued.
	 */
	public static function force_regenerate_all() {
		// No global "is plugin ready" bail — a site with the type-level
		// Source dropdown set to Off may still have specific posts whose
		// per-post Source is set to Auto-screenshot, and "Regenerate All"
		// should regenerate those.
		// DOIG_Source_Resolver::needs_screenshot( $post_id ) inside the
		// loop below handles the type default + per-post override resolution
		// correctly (M4.2); the loop is a fast no-op if no posts qualify.

		DOIG_Logger::info( 'Force regenerate all started.' );

		// Cancel any in-progress queue run and start fresh.
		$existing = wp_next_scheduled( 'doig_process_queue' );
		if ( $existing ) {
			wp_unschedule_event( $existing, 'doig_process_queue' );
		}
		delete_option( self::QUEUE_OPTION );

		$query = new WP_Query( array(
			'post_type'      => self::supported_post_types(),
			'post_status'    => 'publish',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
			// Force regenerate: explicit admin action, runs once per click.
			// Same two-arm exclusion as the daily refresh (legacy
			// _doig_enabled='0' + 2.4.0 per-post Source in Featured / Custom
			// / Off). Tightened 2.4.1.
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			'meta_query'     => array(
				'relation' => 'AND',
				array(
					'relation' => 'OR',
					array(
						'key'     => '_doig_enabled',
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'     => '_doig_enabled',
						'value'   => '0',
						'compare' => '!=',
					),
				),
				array(
					'relation' => 'OR',
					array(
						'key'     => '_doig_source',
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'     => '_doig_source',
						'value'   => array( 'featured', 'custom', 'off' ),
						'compare' => 'NOT IN',
					),
				),
			),
		) );

		if ( empty( $query->posts ) ) {
			DOIG_Logger::info( 'Force regenerate — no eligible posts found.' );
			return 0;
		}

		// Per-post-type defaults resolved per-row inside is_enabled() since
		// the query returns mixed types (post, page, Premium CPTs).
		$queue = array();

		foreach ( $query->posts as $post_id ) {
			// Source-mode quota gate (M4.2). Replaces the pre-2.4.0
			// is_enabled() filter. Regenerate All only queues posts
			// whose source mode actually needs a screenshot capture -
			// Featured / Custom / Off modes resolve to image sources
			// that don't require proxy quota, so they're skipped here.
			if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
				continue;
			}

			// Cancel any pending individual event so it does not fire alongside the queue.
			self::unschedule( $post_id );

			// Mark as pending immediately so the admin list reflects the queued state.
			DOIG_Post_Meta::set( $post_id, 'status',          'pending' );
			DOIG_Post_Meta::set( $post_id, 'scheduled_at',    time() );
			DOIG_Post_Meta::set( $post_id, 'capture_trigger', 'regenerate_all' );

			$queue[] = $post_id;
		}

		// 2.7.0 M5: Regenerate All covers enabled Screenshot-mode
		// surfaces too - it ignores freshness by definition, so no
		// is_fresh skip here, matching the post behaviour above.
		foreach ( DOIG_Surfaces::enabled_surface_ids() as $surface_id ) {
			wp_clear_scheduled_hook( 'doig_take_screenshot', array( $surface_id ) );
			DOIG_Post_Meta::set( $surface_id, 'status',          'pending' );
			DOIG_Post_Meta::set( $surface_id, 'scheduled_at',    time() );
			DOIG_Post_Meta::set( $surface_id, 'capture_trigger', 'regenerate_all' );
			$queue[] = $surface_id;
		}

		if ( empty( $queue ) ) {
			DOIG_Logger::info( 'Force regenerate — no eligible posts found after filtering.' );
			return 0;
		}

		// Remove any submission retry entries for posts in the new bulk queue.
		// The fresh capture takes precedence; there is no point retrying an old
		// submission for a post that is about to be freshly re-submitted.
		$retry_queue = (array) get_option( self::RETRY_OPTION, array() );
		if ( ! empty( $retry_queue ) ) {
			$queue_ids   = array_flip( $queue ); // for O(1) lookup
			$retry_queue = array_values(
				array_filter( $retry_queue, function( $entry ) use ( $queue_ids ) {
					return ! isset( $queue_ids[ (int) $entry['post_id'] ] );
				} )
			);
			update_option( self::RETRY_OPTION, $retry_queue, false );
		}

		update_option( self::QUEUE_OPTION, $queue, false );
		wp_schedule_single_event( time(), 'doig_process_queue' );

		// Chain kick-off (2.6.1): start submitting immediately instead of
		// waiting for the next cron opportunity.
		self::dispatch_chain();

		$queued = count( $queue );
		DOIG_Logger::info(
			"Force regenerate complete — {$queued} post(s) queued from " . count( $query->posts ) . ' checked.'
		);

		return $queued;
	}

	// -------------------------------------------------------------------------
	// Cache-flip orphan sweep (2.4.0 M5)
	// -------------------------------------------------------------------------

	/**
	 * Weekly sweep that cleans up cached screenshots whose resolved
	 * Source mode no longer needs them.
	 *
	 * Customers can switch a content type (or an individual post) from
	 * Auto-screenshot to Featured / Featured-with-fallback / Custom / Off
	 * at any time. The previously cached WebP file then becomes
	 * orphaned: nothing emits it, but it still occupies space in
	 * `wp-content/uploads/dynamic-og-images/`. This sweep cleans up
	 * with a 14-day grace period (see ORPHAN_GRACE_SECONDS).
	 *
	 * Two-stage state machine for each cached post:
	 *
	 *   1. First sweep that observes !needs_screenshot()
	 *      AND no `_doig_orphaned_at` set yet:
	 *      → mark `_doig_orphaned_at = time()`. Cache file stays.
	 *
	 *   2. Subsequent sweep that observes `_doig_orphaned_at` set:
	 *      → if needs_screenshot() flipped back to true:
	 *           clear `_doig_orphaned_at` (the customer changed
	 *           their mind; preserve the cache, no quota burn).
	 *      → else if `_doig_orphaned_at` is at least
	 *           ORPHAN_GRACE_SECONDS old:
	 *           DOIG_Cache::purge() the post (deletes the file and
	 *           clears the cache meta including `_doig_orphaned_at`
	 *           itself).
	 *      → else: leave it. The clock is still ticking.
	 *
	 * The 14-day clock starts from the first sweep that observed the
	 * orphan condition, not from the customer's flip event. Worst case
	 * is 14 + 7 = 21 days from flip to delete; best case (sweep runs
	 * the moment after flip) is 14 days. Average is 17.5 days. The
	 * Q4 spec language ("14-day mark") was deliberately fuzzy enough
	 * to accommodate this sweep-driven implementation; flipping the
	 * clock to start from the flip event itself would require hooking
	 * every code path that writes `_doig_source` or saves Settings,
	 * which is more surface area for the same customer outcome.
	 *
	 * Per Q4 lock: same semantics apply to all non-screenshot modes
	 * including Off (consistency over privacy-immediate-delete). A
	 * future "Delete cached screenshot now" Media Library action can
	 * be added in 2.4.1 if any customer asks for it.
	 *
	 * Connectivity gate: a disconnected plugin can still run the
	 * sweep because no external service is consulted. The whole sweep
	 * is local file + meta work.
	 */
	public static function handle_orphan_sweep() {
		// Favicon check (2.6.0) rides the same weekly schedule. Runs FIRST,
		// before the early return below - the favicon check must run even
		// when no posts have cached screenshots.
		self::check_site_favicon();

		// Find every post that currently has a cached screenshot. The
		// meta_query is bounded (it only matches posts with a non-empty
		// `_doig_cached_url`, which only exists after a successful capture)
		// so it stays cheap even on large sites.
		$query = new WP_Query( array(
			'post_type'      => 'any',
			'post_status'    => 'any',
			'posts_per_page' => -1,
			'fields'         => 'ids',
			'no_found_rows'  => true,
			// Weekly sweep against a single key (`_doig_cached_url`) -
			// not a recurring tight-loop query, and not joined to
			// post_content. Bounded by the cache size, which is itself
			// bounded by the customer's plan quota.
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			'meta_query'     => array(
				array(
					'key'     => '_doig_cached_url',
					'value'   => '',
					'compare' => '!=',
				),
			),
		) );

		if ( empty( $query->posts ) ) {
			DOIG_Logger::debug( 'Orphan sweep — no cached posts found, nothing to do.' );
			return;
		}

		$marked  = 0; // Newly noticed orphans (first sweep that saw them).
		$cleared = 0; // Posts whose Source flipped back to needing screenshot.
		$purged  = 0; // Posts deleted (orphaned for 14+ days).
		$cutoff  = time() - self::ORPHAN_GRACE_SECONDS;

		foreach ( $query->posts as $post_id ) {
			$needs       = DOIG_Source_Resolver::needs_screenshot( $post_id );
			$orphaned_at = (int) DOIG_Post_Meta::get( $post_id, 'orphaned_at' );

			if ( $needs ) {
				// Cache is still needed. Clear any stale orphan flag.
				if ( $orphaned_at > 0 ) {
					DOIG_Post_Meta::set( $post_id, 'orphaned_at', '' );
					$cleared++;
					DOIG_Logger::debug(
						'Orphan flag cleared — Source mode flipped back to needing a screenshot.', array( 'post_id' => $post_id ) );
				}
				continue;
			}

			// Cache is not needed.
			if ( $orphaned_at <= 0 ) {
				// First time this post has been noticed as orphaned.
				// Mark the clock and leave the cache in place.
				DOIG_Post_Meta::set( $post_id, 'orphaned_at', time() );
				$marked++;
				continue;
			}

			// Already orphaned in a previous sweep. Check the age.
			if ( $orphaned_at < $cutoff ) {
				// 14+ days orphaned — purge. Cache::purge() clears
				// `_doig_orphaned_at` along with the rest of the
				// cache meta, so no extra cleanup needed here.
				DOIG_Cache::purge( $post_id );
				$purged++;
				DOIG_Logger::debug(
					sprintf(
						'Orphan purged — cached screenshot was unused for %d days.',
						(int) ( ( time() - $orphaned_at ) / DAY_IN_SECONDS )
					), array( 'post_id' => $post_id ) );
			}
			// else: leave it. The clock is still ticking.
		}

		DOIG_Logger::info(
			sprintf(
				'Orphan sweep complete — %d cached post(s) checked, %d newly marked, %d cleared (flipped back), %d purged.',
				count( $query->posts ),
				$marked,
				$cleared,
				$purged
			)
		);
	}

	/**
	 * Weekly favicon check (2.6.0, decisions locked 2026-07-06).
	 *
	 * Social platforms (Slack / Discord / WhatsApp) render a broken globe
	 * icon next to the share preview when the site has no favicon, which
	 * makes an otherwise-good og:image look incomplete. This check nudges
	 * the customer via a dismissible Dashboard banner - INFO ONLY: it
	 * never blocks, delays or gates a capture, and it costs at most two
	 * HTTP requests per site per week.
	 *
	 * Detection layers, cheapest first:
	 *   1. has_site_icon() - a WP Site Icon means core emits the link
	 *      tags itself. No HTTP needed.
	 *   2. HEAD /favicon.ico - the classic well-known location.
	 *   3. GET the home page and scan for a <link rel="...icon..."> tag
	 *      (covers theme- or plugin-emitted favicons at custom paths).
	 *
	 * Results land in two options read by the Dashboard banner:
	 *   - doig_favicon_missing    1 when no favicon was found, 0 when ok.
	 *   - doig_favicon_checked_at unix timestamp of this check. A check
	 *     newer than the per-user dismissal re-surfaces the banner when
	 *     the favicon is still missing (per the locked spec).
	 */
	public static function check_site_favicon() {
		// SSAA owns favicon_missing since 2026-07-24 (_standards DECISIONS):
		// its rendered-homepage audit is authoritative. When it answers the
		// shared owner filter, DOIG skips its own weekly probe entirely - the
		// shared site-notices dedupe would suppress the render anyway; this
		// skips the detection work too.
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- deliberately CROSS-PLUGIN: 'plugupp' is this vendor's org-level prefix and this filter is the shared ownership/deferral channel between PlugUpp plugins (same annotation as the vendored class-site-notices.php, where the filter is applied); a per-plugin prefix would defeat it.
		if ( apply_filters( 'plugupp_site_notice_owner_favicon_missing', false ) ) {
			return;
		}

		$found = false;

		// Layer 1: WP Site Icon - free, no HTTP.
		if ( function_exists( 'has_site_icon' ) && has_site_icon() ) {
			$found = true;
		}

		// Layer 2: /favicon.ico at the site root.
		if ( ! $found ) {
			$response = wp_remote_head( home_url( '/favicon.ico' ), array( 'timeout' => 5 ) );
			if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
				$found = true;
			}
		}

		// Layer 3: a <link rel="icon"> (or shortcut icon / apple-touch-icon)
		// in the rendered home page.
		if ( ! $found ) {
			$response = wp_remote_get( home_url( '/' ), array( 'timeout' => 5 ) );
			if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
				$body = (string) wp_remote_retrieve_body( $response );
				if ( '' !== $body && preg_match( '/<link[^>]+rel=["\'][^"\']*icon[^"\']*["\']/i', $body ) ) {
					$found = true;
				}
			}
		}

		update_option( 'doig_favicon_missing', $found ? 0 : 1, false );
		update_option( 'doig_favicon_checked_at', time(), false );

		if ( $found ) {
			DOIG_Logger::debug( 'Favicon check - favicon present.' );
		} else {
			DOIG_Logger::info( 'Favicon check - no favicon found. Share previews may show a broken globe icon next to the og:image.' );
		}
	}

	// -------------------------------------------------------------------------
	// Post lifecycle hooks
	// -------------------------------------------------------------------------

	/**
	 * Fires when a post transitions status. Schedules a screenshot on first publish.
	 *
	 * @param string  $new_status
	 * @param string  $old_status
	 * @param WP_Post $post
	 */
	public static function on_post_status_change( $new_status, $old_status, $post ) {
		if ( 'publish' !== $new_status ) {
			return;
		}

		if ( ! in_array( $post->post_type, self::supported_post_types(), true ) ) {
			return;
		}

		// Only trigger on first publish (not re-saves of already-published posts)
		if ( 'publish' === $old_status ) {
			return;
		}

		// Source-mode quota gate (M4.2). schedule() also gates internally,
		// but checking here keeps the log message accurate and avoids the
		// unnecessary call when we already know the answer.
		if ( ! DOIG_Source_Resolver::needs_screenshot( $post->ID ) ) {
			DOIG_Logger::debug(
				sprintf( 'First publish — skipped, source mode is %s.', DOIG_Source_Resolver::resolve_mode( $post->ID ) ?: 'inherit' ), array( 'post_id' => $post->ID ) );
			return;
		}

		DOIG_Logger::debug( 'First publish — scheduling screenshot.', array( 'post_id' => $post->ID ) );

		// Small delay to allow WordPress to finish saving and permalink to resolve.
		self::schedule( $post->ID, 10 );
	}

	/**
	 * Page builders we recognise: builder name => meta key that holds the
	 * builder's content. When any of these meta keys is updated, the
	 * plugin treats it as a content change and schedules a regen.
	 *
	 * Filterable via doig_render_affecting_meta_keys so site owners can
	 * add support for builders we don't yet list. Returns just the meta
	 * key list (for the hook check); supported_builders() returns the
	 * full name+key map (for UI rendering).
	 *
	 * Confirmed via vendor docs / official forums:
	 *   - Elementor:      _elementor_data
	 *   - Beaver Builder: _fl_builder_data
	 *   - Divi:           _et_pb_use_builder, _et_pb_page_layout
	 *   - BeTheme:        mfn-page-items (base64-encoded - Muffin Builder forum)
	 *   - Oxygen:         _oxygen_shortcodes
	 *   - Themify:        _themify_builder_settings_json
	 *   - Breakdance:     _breakdance_data
	 *
	 * WPBakery is not on the list because it stores content as shortcodes
	 * in post_content - already covered by the post_updated content diff.
	 *
	 * @return array<string, string> [Builder name => meta key]
	 */
	public static function supported_builders() {
		return apply_filters( 'doig_supported_builders', array(
			'Elementor'        => '_elementor_data',
			'Beaver Builder'   => '_fl_builder_data',
			'Divi (Elegant Themes)' => '_et_pb_use_builder',
			'BeTheme (Muffin Builder)' => 'mfn-page-items',
			'Oxygen'           => '_oxygen_shortcodes',
			'Themify Builder'  => '_themify_builder_settings_json',
			'Breakdance'       => '_breakdance_data',
		) );
	}

	/**
	 * Just the meta keys we watch, sourced from supported_builders() plus
	 * any extras added via the doig_render_affecting_meta_keys filter
	 * (for sites that want to add support for a builder we don't list
	 * without overriding the full builder UI list).
	 *
	 * @return array<int, string>
	 */
	private static function render_affecting_meta_keys() {
		$keys = array_values( self::supported_builders() );

		// Extra Divi key worth watching separately - some Divi versions
		// only touch this without _et_pb_use_builder on incremental edits.
		$keys[] = '_et_pb_page_layout';

		return array_values( array_unique( apply_filters( 'doig_render_affecting_meta_keys', $keys ) ) );
	}

	/**
	 * Fires whenever a post meta value is updated or added. If the meta
	 * key is one of the page-builder content-bearing keys we recognise,
	 * schedules a regen. This is how Elementor / BeTheme / Divi / Beaver
	 * Builder / etc. saves get caught - their content lives in meta, so
	 * post_updated's content-diff sees no change on the post row itself.
	 *
	 * Hook fires once per meta key write, so a single builder save that
	 * touches 5 keys would trigger this 5 times. The wp_next_scheduled
	 * check inside schedule() collapses that down to a single cron event.
	 *
	 * @param int    $meta_id
	 * @param int    $post_id
	 * @param string $meta_key
	 * @param mixed  $meta_value
	 */
	public static function on_render_meta_updated( $meta_id, $post_id, $meta_key, $meta_value ) {
		if ( ! in_array( $meta_key, self::render_affecting_meta_keys(), true ) ) {
			return;
		}

		$post = get_post( $post_id );
		if ( ! $post ) {
			return;
		}

		if ( 'publish' !== $post->post_status ) {
			return;
		}

		if ( ! in_array( $post->post_type, self::supported_post_types(), true ) ) {
			return;
		}

		if ( ! DOIG_Settings::get( 'regenerate_on_update' ) ) {
			return;
		}

		// Source-mode quota gate (M4.2). A page-builder save on a
		// Featured / Custom / Off post does not need a fresh capture.
		// schedule() also gates internally; checking here avoids the
		// unschedule()+schedule() churn for the no-op case.
		if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
			return;
		}

		DOIG_Logger::debug(
			sprintf( 'Builder meta updated (%s) — rescheduling screenshot.', $meta_key ), array( 'post_id' => $post_id ) );

		self::unschedule( $post_id );
		self::schedule( $post_id, 15 );
	}

	/**
	 * Fires on post_updated. Schedules a refresh if the post's visible
	 * content actually changed (post_content / post_title / post_excerpt
	 * differ between before and after) AND regenerate_on_update is on.
	 *
	 * Switched from save_post in 2.0.38: save_post fires for every save
	 * including Update-with-no-changes clicks, sidebar metabox writes,
	 * custom-field tweaks, and auto-save shims. Most of those don't
	 * change what the screenshot service would render, so rescheduling
	 * a regen on each one wastes screenshot credit (~1 credit per
	 * spurious schedule). post_updated gives us the before/after pair
	 * needed to detect a real content diff.
	 *
	 * Note: capture-setting fields on the DOIG sidebar don't trigger
	 * an auto-regen by this hook. A user changing one of those is
	 * almost certainly about to click Regenerate Now to preview the
	 * effect; auto-firing on the meta change would just produce a
	 * duplicate job. The Regenerate Now REST endpoint also unschedules
	 * any pending event from this hook so the two paths cannot collide.
	 *
	 * @param int     $post_id
	 * @param WP_Post $post_after
	 * @param WP_Post $post_before
	 */
	public static function on_post_updated( $post_id, $post_after, $post_before ) {
		// Only auto-regen when something the screenshot would actually
		// re-render has changed. Pure meta updates, slug-only changes,
		// post_modified-only updates, and "Update clicked but nothing
		// changed" no-ops all skip past this guard.
		$content_changed = (
			$post_after->post_content !== $post_before->post_content
			|| $post_after->post_title  !== $post_before->post_title
			|| $post_after->post_excerpt !== $post_before->post_excerpt
		);

		if ( ! $content_changed ) {
			return;
		}

		if ( 'publish' !== $post_after->post_status ) {
			return;
		}

		if ( ! in_array( $post_after->post_type, self::supported_post_types(), true ) ) {
			return;
		}

		if ( ! DOIG_Settings::get( 'regenerate_on_update' ) ) {
			return;
		}

		// Source-mode quota gate (M4.2). A content edit on a Featured /
		// Custom / Off post does not need a fresh capture.
		if ( ! DOIG_Source_Resolver::needs_screenshot( $post_id ) ) {
			return;
		}

		DOIG_Logger::debug( 'Post content updated — rescheduling screenshot (regenerate_on_update enabled).', array( 'post_id' => $post_id ) );

		// Unschedule any existing job and reschedule fresh.
		self::unschedule( $post_id );
		self::schedule( $post_id, 15 );
	}

	/**
	 * Render a one-line breakdown of bulk-queue outcomes for the
	 * "Queue complete" log line.
	 *
	 * Input is the tally array maintained by handle_process_queue, keyed
	 * on the outcome strings returned by handle_take_screenshot.
	 *
	 * Returns a phrase such as:
	 *   "0 submitted, 11 skipped (11 plugin disabled)."
	 *   "8 submitted, 1 queued for retry, 2 skipped (2 not published)."
	 *   "11 submitted."
	 *
	 * Categories with a zero count are omitted to keep the line scannable
	 * unless the entire batch was empty, in which case we render a stub
	 * so the log still makes sense.
	 *
	 * @param array<string, int> $tally
	 * @return string
	 */
	private static function format_queue_outcome_summary( array $tally ) {
		$parts = array();

		if ( $tally['submitted'] > 0 ) {
			$parts[] = sprintf( '%d submitted', $tally['submitted'] );
		}
		if ( $tally['submission_retry'] > 0 ) {
			$parts[] = sprintf( '%d queued for retry', $tally['submission_retry'] );
		}
		if ( $tally['submission_failed'] > 0 ) {
			$parts[] = sprintf( '%d failed', $tally['submission_failed'] );
		}

		$skipped_total = $tally['skipped_disabled']
		               + $tally['skipped_unpublished']
		               + $tally['skipped_locked']
		               + $tally['skipped_not_connected'];

		if ( $skipped_total > 0 ) {
			$reasons = array();
			if ( $tally['skipped_disabled'] > 0 ) {
				$reasons[] = sprintf( '%d screenshot generation off', $tally['skipped_disabled'] );
			}
			if ( $tally['skipped_unpublished'] > 0 ) {
				$reasons[] = sprintf( '%d not published', $tally['skipped_unpublished'] );
			}
			if ( $tally['skipped_locked'] > 0 ) {
				$reasons[] = sprintf( '%d already in progress', $tally['skipped_locked'] );
			}
			if ( $tally['skipped_not_connected'] > 0 ) {
				$reasons[] = sprintf( '%d screenshot server not connected', $tally['skipped_not_connected'] );
			}
			$parts[] = sprintf( '%d skipped (%s)', $skipped_total, implode( '; ', $reasons ) );
		}

		if ( empty( $parts ) ) {
			return 'No work to do.';
		}

		return implode( ', ', $parts ) . '.';
	}
}
