<?php
/**
 * Scan controller + engine seam handler.
 *
 * Implements SSAA_Engine_Handler (the shared render engine calls these) and owns
 * SSAA's single-phase scan orchestration: discover descriptors (posts plus the
 * synthesized archive pages - see SSAA_Discovery), render each through the
 * engine, analyse the HTML, persist the result. SSAA is single-phase (one queue;
 * archives ride the same queue as posts, there is no second URL-check pass), so
 * process_url_batch() is an `ok` no-op and there is no phase chaining.
 *
 * Adopted (engine adoption phase 2 - docs/SPEC-engine-adoption.md) from the old
 * SSAA_Renderer + SSAA_Scan_Queue + SSAA_Content_Discovery: the engine now owns
 * rendering, the queue, discovery and the exclusion store; this handler wraps
 * SSAA's analyzer + results persistence around the engine seam. Runtime names are
 * prefix-derived ('SSAA'), so the door query vars, the ssaa_scan_queue option /
 * ssaa_process_queue cron, the ssaa_excluded_ids store, the ssaa.log file and
 * every ssaa_* extension filter premium add-ons hook are unchanged.
 *
 * @package search-social-appearance-audit
 */

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

class SSAA_Scan implements SSAA_Engine_Handler {

	/** The single queue's name in the engine config (single-phase: one queue). */
	const QUEUE = 'scan';

	/** @var SSAA_Scan|null */
	private static $instance = null;

	/**
	 * The single handler instance the engine is booted with.
	 *
	 * @return SSAA_Scan
	 */
	public static function instance() {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Boot the shared render engine with SSAA's config + this handler. Called
	 * from SSAA_Plugin::init() on plugins_loaded. SSAA_Engine::boot() is
	 * idempotent and registers the render door, the queue cron and the
	 * core-list-table exclusion bulk actions.
	 */
	public static function boot_engine() {
		$handler = self::instance();
		SSAA_Engine::boot( self::engine_config( $handler ), $handler );
		// One-shot recovery rescan after a degraded scan (engine 0.4.0).
		add_action( 'ssaa_recovery_scan', array( __CLASS__, 'run_recovery' ) );
	}

	/**
	 * The engine boot config. Runtime names (door query vars, queue option /
	 * cron / lock, exclusion store, log file) are derived by the engine from
	 * 'prefix' => 'SSAA', so they are exactly the ssaa_* names SSAA used before.
	 *
	 * @param SSAA_Scan $handler The seam handler (queue processor + callbacks).
	 * @return array
	 */
	public static function engine_config( $handler ) {
		return array(
			'prefix'           => 'SSAA',
			'slug'             => SSAA_PLUGIN_SLUG,
			'version'          => SSAA_VERSION,
			'post_types'       => (array) SSAA_Settings::get( 'scan_post_types', array( 'post', 'page' ) ),
			'post_statuses'    => array( 'publish' ),
			'log_level_getter' => static function () {
				return SSAA_Settings::get( 'log_level', 'info' );
			},
			'logs'             => array(
				// The canonical Logs tab (shared-logging component, SSAA_Log_UI);
				// engine boot passes these through to SSAA_Log::boot().
				'option_key'    => 'ssaa_settings',
				'settings_slug' => 'ssaa-settings',
			),
			'exclusions'       => array(
				'option_key'    => 'ssaa_excluded_ids',
				// "Product: Action" label format (matching DOIG's "OG image: ..." and
				// LIHA's "Link Health audit: ...") so each PlugUpp plugin's bulk actions
				// group together and read unambiguously when several are active on the
				// same core list table. 'noun' drives the engine's success notice so it
				// names the same audit as the labels (the engine default is "scan").
				'exclude_label' => __( 'Appearance audit: Exclude', 'search-social-appearance-audit' ),
				'include_label' => __( 'Appearance audit: Include', 'search-social-appearance-audit' ),
				'noun'          => __( 'Appearance audit', 'search-social-appearance-audit' ),
			),
			'queues'           => array(
				// Single-phase: one queue rendering each post descriptor.
				self::QUEUE => array(
					'option_key'  => 'ssaa_scan_queue',
					'cron_hook'   => 'ssaa_process_queue',
					'lock_key'    => 'ssaa_queue_lock',
					'web_budget'  => 60,
					'ajax_budget' => 12,
					'max_retries' => 2,
					'processor'   => array( $handler, 'process_page' ),
					'on_done'     => array( __CLASS__, 'on_scan_complete' ),
					'on_progress' => array( __CLASS__, 'on_progress' ),
					'on_cancel'   => array( __CLASS__, 'on_cancel' ),
				),
			),
		);
	}

	// -------------------------------------------------------------------------
	// Controller (admin AJAX + the premium scheduled scan call these)
	// -------------------------------------------------------------------------

	/**
	 * Start a full scan: build descriptors from discovery, queue them. A full
	 * scan covers everything currently discoverable, so completion prunes rows
	 * the scan did not touch.
	 *
	 * @param string $origin Informational tag (passed through to on_done).
	 * @return array Queue state.
	 */
	public static function start( $origin = 'manual' ) {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( ! $queue ) {
			return self::idle_state();
		}
		return $queue->start_with( self::instance()->discover(), $origin, true );
	}

	/**
	 * Queue a specific set of post IDs (bulk re-scan). Partial - completion does
	 * NOT prune untouched rows.
	 *
	 * @param int[]  $ids    Post IDs.
	 * @param string $origin Informational tag.
	 * @return array Queue state.
	 */
	public static function start_with_ids( array $ids, $origin = 'manual' ) {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( ! $queue ) {
			return self::idle_state();
		}
		$descriptors = array();
		foreach ( array_values( array_filter( array_map( 'absint', $ids ) ) ) as $id ) {
			$descriptor = SSAA_Engine_Discovery::describe_post( $id );
			if ( null !== $descriptor ) {
				$descriptors[] = $descriptor;
			}
		}
		return $queue->start_with( $descriptors, $origin, false );
	}

	/**
	 * Queue a specific set of refs (bulk re-scan of mixed post + archive rows).
	 * Partial - completion does NOT prune untouched rows. Post refs resolve via
	 * describe_post; url refs via the live synthesized set (a no-longer-
	 * synthesized archive is silently skipped - its row is pruned by the next
	 * full scan or per-row Re-scan).
	 *
	 * @param string[] $refs   Row refs ('post:{ID}' / 'url:{sha1}'; bare IDs ok).
	 * @param string   $origin Informational tag.
	 * @return array Queue state.
	 */
	public static function start_with_refs( array $refs, $origin = 'manual' ) {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( ! $queue ) {
			return self::idle_state();
		}

		$post_ids    = array();
		$url_refs    = array();
		$descriptors = array();
		foreach ( $refs as $raw ) {
			$ref = self::normalize_ref( $raw );
			if ( 0 === strpos( $ref, 'post:' ) ) {
				$post_ids[] = absint( substr( $ref, 5 ) );
			} elseif ( 0 === strpos( $ref, 'url:' ) ) {
				$url_refs[ $ref ] = true;
			}
		}

		foreach ( array_values( array_filter( array_unique( $post_ids ) ) ) as $id ) {
			$descriptor = SSAA_Engine_Discovery::describe_post( $id );
			if ( null !== $descriptor ) {
				$descriptors[] = $descriptor;
			}
		}
		if ( $url_refs ) {
			foreach ( array_merge( SSAA_Discovery::synthesized(), SSAA_Discovery::site_descriptor() ) as $descriptor ) {
				if ( isset( $descriptor['ref'] ) && isset( $url_refs[ $descriptor['ref'] ] ) ) {
					$descriptors[] = $descriptor;
				}
			}
		}

		return $queue->start_with( $descriptors, $origin, false );
	}

	/**
	 * Re-scan one item synchronously (the per-row "Re-scan", outside the queue).
	 *
	 * @param int $post_id Post ID.
	 * @return array Outcome.
	 */
	public static function rescan_item( $post_id ) {
		$descriptor = SSAA_Engine_Discovery::describe_post( (int) $post_id );
		if ( null === $descriptor ) {
			SSAA_Results::delete( (int) $post_id );
			return array( 'status' => 'gone', 'retryable' => false );
		}
		return self::instance()->process_page( $descriptor );
	}

	/**
	 * Re-scan one item by ref - the archive-capable variant of rescan_item().
	 * Post refs delegate to rescan_item(); url refs rebuild the descriptor
	 * from the live synthesized set (so a term/archive that no longer exists
	 * correctly comes back 'gone' and its stale row is dropped).
	 *
	 * @param string $ref Row ref ('post:{ID}' / 'url:{sha1}').
	 * @return array Outcome.
	 */
	public static function rescan_ref( $ref ) {
		$ref = self::normalize_ref( $ref );

		if ( 0 === strpos( $ref, 'post:' ) ) {
			return self::rescan_item( absint( substr( $ref, 5 ) ) );
		}
		if ( 0 !== strpos( $ref, 'url:' ) ) {
			return array( 'status' => 'gone', 'retryable' => false );
		}

		foreach ( array_merge( SSAA_Discovery::synthesized(), SSAA_Discovery::site_descriptor() ) as $descriptor ) {
			if ( isset( $descriptor['ref'] ) && $descriptor['ref'] === $ref ) {
				return self::instance()->process_page( $descriptor );
			}
		}

		// No longer synthesized (term deleted, setting off): drop the row.
		SSAA_Results::delete_by_ref( $ref );
		SSAA_Results::refresh_summary();
		return array( 'status' => 'gone', 'retryable' => false );
	}

	/**
	 * Current scan state.
	 *
	 * @return array { running, total, remaining, done }
	 */
	public static function state() {
		$queue = SSAA_Engine::queue( self::QUEUE );
		return $queue ? $queue->state() : self::idle_state();
	}

	/**
	 * Process one browser-driven (AJAX) batch.
	 *
	 * @return array Queue state plus 'processed'.
	 */
	public static function tick() {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( ! $queue ) {
			return array_merge( self::idle_state(), array( 'processed' => 0 ) );
		}
		// Preserve SSAA's ssaa_ajax_batch_budget filter over the configured budget.
		$budget = (int) apply_filters( 'ssaa_ajax_batch_budget', $queue->ajax_budget() );
		return $queue->process_batch( $budget );
	}

	/**
	 * Process one cron-driven batch (web budget). Used by the premium scheduled
	 * scan to run the first batch within its own cron tick.
	 */
	public static function cron_tick() {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( $queue ) {
			$queue->cron_tick();
		}
	}

	/**
	 * Cancel any running scan. Results from already-processed items are kept.
	 *
	 * @return array Idle state.
	 */
	public static function cancel() {
		$queue = SSAA_Engine::queue( self::QUEUE );
		if ( $queue ) {
			$queue->cancel();
		}
		return self::idle_state();
	}

	/**
	 * The "no scan running" state shape (matches the engine queue's).
	 *
	 * @return array
	 */
	private static function idle_state() {
		return array( 'running' => false, 'total' => 0, 'remaining' => 0, 'done' => 0 );
	}

	// -------------------------------------------------------------------------
	// SSAA_Engine_Handler implementation
	// -------------------------------------------------------------------------

	/**
	 * Page descriptors for a scan: the engine post/page descriptors plus the
	 * synthesized archive-page descriptors (SSAA_Discovery), with excluded
	 * refs dropped.
	 *
	 * @return array[]
	 */
	public function discover() {
		return SSAA_Engine_Exclusions::filter_descriptors( SSAA_Discovery::descriptors() );
	}

	/**
	 * Render one descriptor through the engine, analyse the HTML, persist the
	 * result. Posts render via fetch_post; url-kind (archive) descriptors via
	 * fetch_url - NOT speculative: our synthesized page-1 archive URLs must
	 * exist, so a 404 is a finding-worthy render error, not benign. This is
	 * the body of the old SSAA_Scan_Queue::scan_item(), reading its identity
	 * from the descriptor instead of a separate describe().
	 *
	 * @param array $descriptor One descriptor from discover().
	 * @return array Outcome { status: ok|error|gone, retryable, error_code?, http_status? }.
	 */
	public function process_page( array $descriptor ) {
		$post_id = isset( $descriptor['post_id'] ) ? (int) $descriptor['post_id'] : 0;
		$is_url  = isset( $descriptor['kind'] ) && 'url' === $descriptor['kind'];
		$base    = self::base_from_descriptor( $descriptor );

		// The Site row (type 'site', 1.2.0) audits robots.txt/AI access, not a
		// rendered HTML page - it never goes through the renderer or analyzer.
		if ( isset( $descriptor['type'] ) && 'site' === $descriptor['type'] ) {
			return self::process_site_row( $base );
		}

		$rendered = $is_url
			? SSAA_Engine_Renderer::fetch_url( $base['permalink'] )
			: SSAA_Engine_Renderer::fetch_post( $post_id );

		if ( is_wp_error( $rendered ) ) {
			// The post vanished since the queue was built: drop any stale row and
			// report 'gone' (not a scan error).
			if ( 'engine_target_not_found' === $rendered->get_error_code() ) {
				SSAA_Results::delete_by_ref( $base['ref'] );
				return array( 'status' => 'gone', 'retryable' => false );
			}

			$data        = (array) $rendered->get_error_data();
			$http_status = isset( $data['http_status'] ) ? (int) $data['http_status'] : 0;
			$error_code  = $rendered->get_error_code();

			// Deferrable (engine 0.4.0): the site is in maintenance mode or
			// asked us to retry later. NOT this page's fault - the queue pauses
			// as a whole and resumes, with no retry consumed. Mark the row
			// PROVISIONALLY failed first (status/error columns only, audit data
			// untouched): if the deferral resolves the successful render
			// overwrites it; if the defer budget exhausts and the queue drops
			// the item, this mark is what keeps the full-scan prune off the row
			// and drives the degraded flag + the recovery rescan. Without it a
			// budget-exhausted drop is invisible to the consumer (LIHA's
			// staging Test B).
			if ( SSAA_Engine_Renderer::is_deferrable( $rendered ) ) {
				SSAA_Results::save_scan_error( $base, array(
					'scan_status'   => 'error',
					'http_status'   => $http_status,
					'error_code'    => $error_code,
					'error_message' => $rendered->get_error_message(),
				) );

				return array(
					'status'        => 'deferred',
					'retryable'     => false,
					'deferrable'    => true,
					'defer_seconds' => SSAA_Engine_Renderer::defer_seconds( $rendered ),
					'error_code'    => $error_code,
				);
			}

			// A failed render records the failure WITHOUT blanking the stored
			// audit: save() is a full-column upsert, so passing only the error
			// fields used to overwrite every audit value with an empty default
			// (the 2026-07-28 maintenance incident). save_scan_error() writes
			// the status/error columns only - the page keeps its last known
			// good values until a scan actually reads it again.
			SSAA_Results::save_scan_error( $base, array(
				'scan_status'   => 'error',
				'http_status'   => $http_status,
				'error_code'    => $error_code,
				'error_message' => $rendered->get_error_message(),
			) );

			return array(
				'status'      => 'error',
				'retryable'   => SSAA_Engine_Renderer::is_retryable( $rendered ),
				'error_code'  => $error_code,
				'http_status' => $http_status,
			);
		}

		$analysis = SSAA_Analyzer::analyze( $rendered['html'], array(
			'post_id'   => $post_id,
			'permalink' => $base['permalink'],
		) );

		/**
		 * Extension point: add-on files augment the analysis with access to the
		 * full rendered HTML (Enhanced scan's main seam).
		 *
		 * @param array  $analysis Analysis result.
		 * @param string $html     Full rendered HTML.
		 * @param array  $context  post_id / permalink.
		 */
		$analysis = (array) apply_filters( 'ssaa_analysis', $analysis, $rendered['html'], array(
			'post_id'   => $post_id,
			'permalink' => $base['permalink'],
		) );

		SSAA_Results::save( array_merge( $base, $analysis['meta'], array(
			'scan_status' => 'ok',
			'http_status' => (int) $rendered['http_status'],
			'issues'      => $analysis['issues'],
			'extras'      => array_merge(
				array(
					'via'           => $rendered['via'],
					'duration_ms'   => $rendered['duration_ms'],
					'sources'       => $analysis['sources'],
					'author_source' => $analysis['author_source'],
				),
				isset( $analysis['extras'] ) ? (array) $analysis['extras'] : array()
			),
		) ) );

		return array( 'status' => 'ok', 'retryable' => false );
	}

	/**
	 * Process the Site row: evaluate AI access (robots.txt matrix + llms.txt
	 * fact - free, SSAA_AI_Access), collect any premium site-level findings
	 * via the seam, persist. The panel data rides extras['ai_access'].
	 *
	 * @param array $base The save() base fields (from base_from_descriptor).
	 * @return array Outcome.
	 */
	private static function process_site_row( array $base ) {
		$access  = SSAA_AI_Access::evaluate( true );
		$favicon = SSAA_Site_Checks::favicon_audit();

		/**
		 * Site-level findings: the FREE defect checks seed the list (a broken
		 * robots.txt is basic issue detection - see
		 * SSAA_AI_Access::site_defect_issues()), then the premium AI-readiness
		 * detector appends its interpretive findings via the seam
		 * (SPEC-ai-readiness s.3.2). Free build: seam has no subscribers, so
		 * the row carries the defect findings only.
		 *
		 * @param array $issues Site-level issues (analyzer issue shape).
		 * @param array $access The evaluated AI-access structure.
		 */
		$issues = (array) apply_filters(
			'ssaa_ai_site_issues',
			array_merge(
				SSAA_AI_Access::site_defect_issues( $access ),
				SSAA_Site_Checks::search_visibility_issues(),
				$favicon['issues']
			),
			$access
		);

		// 'ok' and 'absent' are both definitive states (RFC 9309: absent =
		// no restrictions); only 'unreachable' is a scan error.
		$definitive = in_array( $access['status'], array( 'ok', 'absent' ), true );

		$saved = SSAA_Results::save( array_merge( $base, array(
			'scan_status' => $definitive ? 'ok' : 'error',
			'http_status' => (int) $access['http_status'],
			'error_code'  => $definitive ? '' : 'ai_robots_unreadable',
			'error_message' => $definitive ? '' : __( 'robots.txt could not be read over the public path - the AI access matrix is unavailable for this scan.', 'search-social-appearance-audit' ),
			'issues'      => $issues,
			'extras'      => array( 'ai_access' => $access, 'favicon' => $favicon['facts'] ),
		) ) );

		return array( 'status' => $saved && $definitive ? 'ok' : 'error', 'retryable' => ! $definitive );
	}

	/**
	 * Single-phase: SSAA has no second queue. Required by the interface; ok no-op.
	 *
	 * @param array $batch Unused.
	 * @return array
	 */
	public function process_url_batch( array $batch ) {
		unset( $batch );
		return array( 'status' => 'ok', 'retryable' => false );
	}

	/**
	 * A descriptor's target vanished since the queue was built. Drop its row.
	 * Ref-keyed (v9) so archive refs ('url:...') delete their own row instead
	 * of resolving to post_id 0.
	 *
	 * @param string $ref Engine ref ('post:ID' / 'url:hash').
	 */
	public function on_item_gone( $ref ) {
		$ref = self::normalize_ref( $ref );
		if ( '' !== $ref ) {
			SSAA_Results::delete_by_ref( $ref );
			SSAA_Results::refresh_summary();
		}
	}

	/**
	 * Refs were excluded; drop their stored result rows.
	 *
	 * @param string[] $refs Engine refs.
	 */
	public function on_items_excluded( array $refs ) {
		$changed = false;
		foreach ( $refs as $ref ) {
			$ref = self::normalize_ref( $ref );
			if ( '' !== $ref ) {
				SSAA_Results::delete_by_ref( $ref );
				$changed = true;
			}
		}
		if ( $changed ) {
			SSAA_Results::refresh_summary();
		}
	}

	/**
	 * Refs were re-included; they reappear after the next scan. No-op.
	 *
	 * @param string[] $refs Engine refs.
	 */
	public function on_items_reincluded( array $refs ) {
		unset( $refs );
	}

	// -------------------------------------------------------------------------
	// Queue callbacks
	// -------------------------------------------------------------------------

	/**
	 * The scan queue drained: prune rows a full scan did not touch, refresh the
	 * summary, fire ssaa_scan_completed. The action signature is preserved for
	 * the premium Changes (3 args) + Report (2 args) engines: origin, summary,
	 * is_full.
	 *
	 * @param array $stats Queue completion stats { total, origin, full, started_at }.
	 */
	public static function on_scan_complete( $stats ) {
		$stats  = (array) $stats;
		$origin = isset( $stats['origin'] ) ? (string) $stats['origin'] : 'manual';
		$full   = ! empty( $stats['full'] );

		// Full scans prune rows they did not touch, so stored results never drift
		// from the audited set (partial bulk re-scans skip this).
		if ( $full && ! empty( $stats['started_at'] ) ) {
			SSAA_Results::prune_scanned_before( gmdate( 'Y-m-d H:i:s', (int) $stats['started_at'] ) );
		}

		$summary = SSAA_Results::refresh_summary();

		// Belt-and-braces (engine 0.4.0): the queue counts every item it gave
		// up on, and that count is authoritative for DROPPED items. If it
		// disagrees with the per-row accounting (a consumer bug like the one
		// LIHA's Test-B battery caught), force the degraded flag rather than
		// report a clean scan - wrong-but-cautious beats confidently wrong.
		// The per-row scan_status remains the primary mechanism; this only
		// rescues the FLAG.
		$queue_failures = isset( $stats['failures'] ) ? (int) $stats['failures'] : 0;
		if ( $queue_failures > 0 && empty( $summary['degraded'] ) ) {
			$recorded_pages            = isset( $summary['degraded_pages'] ) ? (int) $summary['degraded_pages'] : 0;
			$summary['degraded']       = true;
			$summary['degraded_pages'] = max( $recorded_pages, $queue_failures );
			update_option( SSAA_Results::SUMMARY_OPTION, $summary, false );
			SSAA_Logger::warning( 'degraded_stats_mismatch', array(
				'queue_failures' => $queue_failures,
				'recorded_pages' => $recorded_pages,
			) );
		}

		SSAA_Logger::info( 'scan_completed', array(
			'total'    => isset( $stats['total'] ) ? (int) $stats['total'] : 0,
			'origin'   => $origin,
			'degraded' => ! empty( $summary['degraded'] ) ? 1 : 0,
		) );

		/**
		 * A scan finished and the results table is fully up to date.
		 *
		 * @param string $origin  What started the scan ('manual', 'scheduled', ...).
		 * @param array  $summary Fresh summary aggregates.
		 * @param bool   $is_full Whether this was a full scan (the change-tracking
		 *                        engine only advances its baseline on full scans).
		 */
		do_action( 'ssaa_scan_completed', $origin, $summary, $full );

		self::maybe_schedule_recovery( $summary, $stats );
	}

	// -------------------------------------------------------------------------
	// Degraded-scan recovery (engine 0.4.0 / 1.3.1)
	// -------------------------------------------------------------------------

	/**
	 * A degraded scan (pages failed to render - e.g. a maintenance window that
	 * outlasted the queue's defer budget) gets ONE automatic recovery rescan a
	 * little later. A recovery scan that is itself degraded does not chain
	 * (origin check), so this can never loop against a persistently-broken
	 * site - after the one attempt, the dashboard's error count and the next
	 * scheduled scan are the fallbacks.
	 *
	 * @param array $summary Fresh summary aggregates.
	 * @param array $stats   Queue completion stats.
	 */
	private static function maybe_schedule_recovery( array $summary, array $stats ) {
		$origin = isset( $stats['origin'] ) ? (string) $stats['origin'] : 'manual';
		if ( empty( $summary['degraded'] ) || 'recovery' === $origin ) {
			return;
		}
		if ( wp_next_scheduled( 'ssaa_recovery_scan' ) ) {
			return;
		}
		wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, 'ssaa_recovery_scan' );
		SSAA_Logger::info( 'recovery_scan_scheduled', array(
			'degraded_pages' => isset( $summary['degraded_pages'] ) ? (int) $summary['degraded_pages'] : 0,
		) );
	}

	/**
	 * Cron handler for the one-shot recovery rescan.
	 */
	public static function run_recovery() {
		$state = self::state();
		if ( ! empty( $state['running'] ) ) {
			return; // A scan is already running; it supersedes the recovery.
		}
		$summary = SSAA_Results::summary();
		if ( empty( $summary['degraded'] ) ) {
			return; // A later scan already recovered.
		}
		SSAA_Logger::info( 'recovery_scan_started', array() );
		self::start( 'recovery' );
		self::cron_tick();
	}

	/**
	 * After a non-final batch: keep the dashboard summary roughly current.
	 *
	 * @param array $state Queue state (unused).
	 */
	public static function on_progress( $state ) {
		unset( $state );
		SSAA_Results::refresh_summary();
	}

	/**
	 * A scan was cancelled: refresh the summary (processed-item results kept).
	 *
	 * @param mixed $arg Unused (the engine passes null).
	 */
	public static function on_cancel( $arg = null ) {
		unset( $arg );
		SSAA_Results::refresh_summary();
	}

	// -------------------------------------------------------------------------
	// Internals
	// -------------------------------------------------------------------------

	/**
	 * Build the SSAA_Results::save() base fields from an engine descriptor (the
	 * descriptor carries the identity the old SSAA_Content_Discovery::describe()
	 * returned: post_id, type, post_status, label/title, author, permalink).
	 * Url-kind (archive) descriptors have no post: post_id 0, the archive kind
	 * in post_type ('home'|'tax'|'date'), 'publish' as post_status (archives
	 * are by nature live pages), and the label as the stored title.
	 *
	 * @param array $descriptor Engine descriptor (post or url kind).
	 * @return array
	 */
	private static function base_from_descriptor( array $descriptor ) {
		$is_url = isset( $descriptor['kind'] ) && 'url' === $descriptor['kind'];

		$post_id = isset( $descriptor['post_id'] ) ? (int) $descriptor['post_id'] : 0;
		$ref     = isset( $descriptor['ref'] ) ? (string) $descriptor['ref'] : '';
		if ( '' === $ref && $post_id > 0 ) {
			$ref = 'post:' . $post_id;
		}

		return array(
			'post_id'     => $is_url ? 0 : $post_id,
			'ref'         => $ref,
			'post_type'   => isset( $descriptor['type'] ) ? (string) $descriptor['type'] : '',
			'post_status' => $is_url ? 'publish' : ( isset( $descriptor['post_status'] ) ? (string) $descriptor['post_status'] : '' ),
			'post_title'  => isset( $descriptor['label'] ) ? (string) $descriptor['label'] : '',
			'wp_author'   => isset( $descriptor['author'] ) ? (string) $descriptor['author'] : '',
			'permalink'   => isset( $descriptor['permalink'] ) ? (string) $descriptor['permalink'] : '',
			/** Extension point: add-on files report a different scan mode. */
			'scan_mode'   => (string) apply_filters( 'ssaa_scan_mode', 'standard' ),
			'scanned_at'  => current_time( 'mysql', true ),
		);
	}

	/**
	 * Canonical ref form: bare digits (legacy callers) become 'post:{id}';
	 * 'post:'/'url:' refs pass through; anything else is ''.
	 *
	 * @param string $ref Engine ref or bare post ID.
	 * @return string
	 */
	private static function normalize_ref( $ref ) {
		$ref = (string) $ref;
		if ( ctype_digit( $ref ) ) {
			return 'post:' . absint( $ref );
		}
		if ( 0 === strpos( $ref, 'post:' ) || 0 === strpos( $ref, 'url:' ) ) {
			return $ref;
		}
		return '';
	}
}
