<?php

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

require_once __DIR__ . '/class-content-miner-rendered-snapshot.php';

/**
 * Resumable acquisition of canonical public-page snapshots.
 *
 * The historical class name remains for option, hook, and extension
 * compatibility. Rendered snapshots are now the primary analysis input.
 */
class Content_Miner_Rendered_Verification {
	const STATE_OPTION = 'content_miner_verification_state';
	const LOCK_KEY = 'content_miner_verification_lock';
	const CRON_HOOK = 'content_miner_process_verification_batch';
	const BATCH_SIZE = 25;
	const WALL_TIME_SECONDS = 20;
	const REQUEST_TIMEOUT = 5;
	const MAX_ATTEMPTS = 2;
	const TEXT_META_KEY = '_content_miner_rendered_text';
	const MAX_RENDERED_HTML_BYTES = 2097152;
	const MAX_RENDERED_TEXT_BYTES = 65536;
	const STRIPPED_ELEMENTS = array( 'nav', 'header', 'footer', 'aside', 'navigation', 'complementary', 'banner', 'contentinfo' );

	private $repository;
	private $memory;
	private $clock;
	private $sleeper;
	private $last_request_at = 0.0;

	public function __construct( $repository, $memory = null, $clock = null, $sleeper = null ) {
		$this->repository = $repository;
		$this->memory = $memory;
		$this->clock = is_callable( $clock ) ? $clock : function () { return microtime( true ); };
		$this->sleeper = is_callable( $sleeper ) ? $sleeper : function ( $microseconds ) { usleep( $microseconds ); };
		add_action( self::CRON_HOOK, array( $this, 'process_batch' ) );
		add_action( 'switch_theme', array( __CLASS__, 'bump_render_epoch' ) );
		add_action( 'activated_plugin', array( __CLASS__, 'bump_render_epoch' ) );
		add_action( 'deactivated_plugin', array( __CLASS__, 'bump_render_epoch' ) );
		add_action( 'upgrader_process_complete', array( __CLASS__, 'bump_render_epoch' ) );
	}

	/**
	 * Start a snapshot walk.
	 *
	 * @param bool $force Ignore fresh snapshots for an explicit operator refresh.
	 * @param bool $process_now Complete one bounded batch in this request.
	 * @param bool $queue_dirty Queue changed snapshots for a separate memory refresh.
	 * @return array<string,mixed>
	 */
	public function start( $force = true, $process_now = true, $queue_dirty = true ) {
		$state = array(
			'job_id' => wp_generate_uuid4(), 'status' => 'queued', 'cursor' => 0,
			'max_id' => $this->repository->get_published_max_id(), 'total' => $this->repository->get_published_total(),
			'processed' => 0, 'fetched' => 0, 'fallback' => 0, 'remaining' => $this->repository->get_published_total(),
			'flagged' => 0, 'skipped' => 0, 'attempts' => 0, 'batch_size' => self::BATCH_SIZE,
			'phase' => 'initial', 'retry_ids' => array(), 'retry_after' => 0, 'force' => (bool) $force,
			'queue_dirty' => (bool) $queue_dirty,
			'request_duration_ms' => 0, 'parse_duration_ms' => 0, 'queue_duration_ms' => 0,
			'fallback_counts' => array(), 'scheduled_at' => gmdate( 'c' ), 'scheduled_delay' => 1,
			'started_at' => gmdate( 'c' ), 'updated_at' => gmdate( 'c' ), 'completed_at' => '', 'last_error' => '',
		);
		update_option( self::STATE_OPTION, $state, false );
		$this->schedule_next( 1 );
		return $process_now ? $this->process_batch( 'foreground' ) : $this->get_state();
	}

	/** Process one item- and wall-time-bounded snapshot batch. */
	public function process_batch( $runner = 'background' ) {
		$state = $this->get_state();
		if ( ! in_array( $state['status'], array( 'queued', 'running' ), true ) || ! $this->acquire_lock( $state['job_id'] ) ) {
			return $state;
		}
		$batch_started = microtime( true );
		$work_before = (int) $state['fetched'] + (int) $state['fallback'];
		$state['status'] = 'running';
		$state['last_runner'] = $this->normalize_runner( $runner );
		$this->record_queue_duration( $state );
		$batch_started = $this->now();
		try {
			if ( 'retry' === $state['phase'] ) {
				$state = $this->process_retry_batch( $state, $batch_started );
			} else {
				$state = $this->process_initial_batch( $state, $batch_started );
			}
		} catch ( Throwable $error ) {
			$state['status'] = 'failed';
			$state['last_error'] = sanitize_text_field( $error->getMessage() );
			$state['updated_at'] = gmdate( 'c' );
			update_option( self::STATE_OPTION, $state, false );
		} finally {
			$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
			update_option( self::STATE_OPTION, $state, false );
			delete_option( self::LOCK_KEY );
		}
		return $state;
	}

	private function process_initial_batch( array $state, $batch_started ) {
		$items = $this->repository->get_published_batch( $state['cursor'], self::BATCH_SIZE, $state['max_id'] );
		if ( empty( $items ) ) {
			return $this->initial_pass_complete( $state );
		}

		$dirty_ids = array();
		$visited = 0;
		foreach ( $items as $item ) {
			if ( $visited > 0 && $this->now() - $batch_started >= self::WALL_TIME_SECONDS ) {
				break;
			}
			++$visited;
			$state['cursor'] = max( (int) $state['cursor'], (int) $item['id'] );
			$snapshot = isset( $item['verification'] ) && is_array( $item['verification'] ) ? $item['verification'] : array();
			$stored = isset( $item['stored_content'] ) ? $item['stored_content'] : '';
			if ( empty( $state['force'] ) && Content_Miner_Rendered_Snapshot::is_fresh( $snapshot, $stored, $item['url'], $item['status'], $item['source_modified_at'] ) ) {
				++$state['fetched'];
				++$state['processed'];
				++$state['skipped'];
				continue;
			}

			$outcome = $this->snapshot_item( $item, 1, $state );
			$dirty_ids[] = (int) $item['id'];
			if ( 'rendered' === $outcome['status'] ) {
				++$state['fetched'];
				++$state['processed'];
			} elseif ( ! empty( $outcome['retryable'] ) ) {
				$state['retry_ids'][] = (int) $item['id'];
				$state['retry_after'] = max( (int) $state['retry_after'], time() + 60 );
			} else {
				$this->count_fallback( $state, $outcome['reason_code'] );
			}
		}
		if ( ! empty( $state['queue_dirty'] ) ) {
			$this->queue_dirty( $dirty_ids );
		}
		$state['retry_ids'] = array_values( array_unique( array_map( 'intval', $state['retry_ids'] ) ) );
		$state['remaining'] = max( 0, (int) $state['total'] - (int) $state['fetched'] - (int) $state['fallback'] );
		$state['updated_at'] = gmdate( 'c' );

		$reached_end = $visited === count( $items )
			&& ( count( $items ) < self::BATCH_SIZE || $state['cursor'] >= $state['max_id'] );
		if ( $reached_end ) {
			return $this->initial_pass_complete( $state );
		}
		return $this->checkpoint_and_schedule( $state, 3 );
	}

	private function initial_pass_complete( array $state ) {
		if ( ! empty( $state['retry_ids'] ) ) {
			$state['phase'] = 'retry';
			$state['updated_at'] = gmdate( 'c' );
			$delay = max( 1, (int) $state['retry_after'] - time() );
			return $this->checkpoint_and_schedule( $state, $delay );
		}
		return $this->complete( $state );
	}

	private function process_retry_batch( array $state, $batch_started ) {
		if ( (int) $state['retry_after'] > time() ) {
			return $this->checkpoint_and_schedule( $state, max( 1, (int) $state['retry_after'] - time() ) );
		}
		$dirty_ids = array();
		$processed = 0;
		while ( ! empty( $state['retry_ids'] ) && $processed < self::BATCH_SIZE ) {
			if ( $processed > 0 && $this->now() - $batch_started >= self::WALL_TIME_SECONDS ) {
				break;
			}
			$post_id = (int) array_shift( $state['retry_ids'] );
			$item = $this->repository->get_item( $post_id );
			if ( ! $item || 'publish' !== $item['status'] ) {
				$this->count_fallback( $state, 'http_not_found' );
				++$processed;
				continue;
			}
			$outcome = $this->snapshot_item( $item, self::MAX_ATTEMPTS, $state );
			$dirty_ids[] = $post_id;
			if ( 'rendered' === $outcome['status'] ) {
				++$state['fetched'];
				++$state['processed'];
			} else {
				$this->count_fallback( $state, $outcome['reason_code'] );
			}
			++$processed;
		}
		if ( ! empty( $state['queue_dirty'] ) ) {
			$this->queue_dirty( $dirty_ids );
		}
		$state['remaining'] = max( 0, (int) $state['total'] - (int) $state['fetched'] - (int) $state['fallback'] );
		$state['updated_at'] = gmdate( 'c' );
		return empty( $state['retry_ids'] ) ? $this->complete( $state ) : $this->checkpoint_and_schedule( $state, 3 );
	}

	/**
	 * Refresh one dirty post outside a full snapshot walk.
	 *
	 * A transient first failure remains pending and is retried by the dirty
	 * queue. The second failure becomes an explicit stored fallback.
	 */
	public function refresh_post( $post_id ) {
		$item = $this->repository->get_item( (int) $post_id );
		if ( ! $item || 'publish' !== $item['status'] ) {
			return array( 'ready' => true, 'input_source' => 'stored_nonpublic' );
		}
		$snapshot = isset( $item['verification'] ) && is_array( $item['verification'] ) ? $item['verification'] : array();
		$stored = isset( $item['stored_content'] ) ? $item['stored_content'] : '';
		if ( Content_Miner_Rendered_Snapshot::is_fresh( $snapshot, $stored, $item['url'], $item['status'], $item['source_modified_at'] ) ) {
			return array( 'ready' => true, 'input_source' => 'rendered' );
		}
		if ( 'retry_pending' === ( isset( $snapshot['status'] ) ? $snapshot['status'] : '' )
			&& (int) ( isset( $snapshot['retry_after'] ) ? $snapshot['retry_after'] : 0 ) > time()
		) {
			return array(
				'ready' => false,
				'input_source' => 'stored_fallback',
				'reason_code' => isset( $snapshot['fallback_reason_code'] ) ? $snapshot['fallback_reason_code'] : 'loopback_error',
				'retry_after' => (int) $snapshot['retry_after'],
			);
		}
		$attempt = 'retry_pending' === ( isset( $snapshot['status'] ) ? $snapshot['status'] : '' )
			? min( self::MAX_ATTEMPTS, max( 2, (int) ( isset( $snapshot['attempts'] ) ? $snapshot['attempts'] + 1 : 2 ) ) )
			: 1;
		$state = $this->get_state();
		$request_before = (int) $state['request_duration_ms'];
		$parse_before = (int) $state['parse_duration_ms'];
		$outcome = $this->snapshot_item( $item, $attempt, $state );
		$state['incremental_request_duration_ms'] = (int) ( isset( $state['incremental_request_duration_ms'] ) ? $state['incremental_request_duration_ms'] : 0 )
			+ max( 0, (int) $state['request_duration_ms'] - $request_before );
		$state['incremental_parse_duration_ms'] = (int) ( isset( $state['incremental_parse_duration_ms'] ) ? $state['incremental_parse_duration_ms'] : 0 )
			+ max( 0, (int) $state['parse_duration_ms'] - $parse_before );
		if ( 'rendered' === $outcome['status'] ) {
			$state['incremental_fetched'] = (int) ( isset( $state['incremental_fetched'] ) ? $state['incremental_fetched'] : 0 ) + 1;
		} elseif ( ! empty( $outcome['retryable'] ) ) {
			$state['incremental_retry_pending'] = (int) ( isset( $state['incremental_retry_pending'] ) ? $state['incremental_retry_pending'] : 0 ) + 1;
		} else {
			$state['incremental_fallback'] = (int) ( isset( $state['incremental_fallback'] ) ? $state['incremental_fallback'] : 0 ) + 1;
			$reason_code = sanitize_key( $outcome['reason_code'] );
			if ( ! isset( $state['incremental_fallback_counts'] ) || ! is_array( $state['incremental_fallback_counts'] ) ) {
				$state['incremental_fallback_counts'] = array();
			}
			$state['incremental_fallback_counts'][ $reason_code ] = isset( $state['incremental_fallback_counts'][ $reason_code ] )
				? (int) $state['incremental_fallback_counts'][ $reason_code ] + 1
				: 1;
		}
		update_option( self::STATE_OPTION, $state, false );
		return array(
			'ready' => empty( $outcome['retryable'] ),
			'input_source' => 'rendered' === $outcome['status'] ? 'rendered' : 'stored_fallback',
			'reason_code' => $outcome['reason_code'],
		);
	}

	/** @return array<string,mixed> */
	public function get_state() {
		$state = get_option( self::STATE_OPTION, array() );
		$state = wp_parse_args(
			is_array( $state ) ? $state : array(),
			array(
				'job_id' => '', 'status' => 'idle', 'cursor' => 0, 'max_id' => 0, 'total' => 0,
				'processed' => 0, 'fetched' => 0, 'fallback' => 0, 'remaining' => 0, 'flagged' => 0,
				'skipped' => 0, 'attempts' => 0, 'batch_size' => self::BATCH_SIZE, 'phase' => 'initial',
				'retry_ids' => array(), 'retry_after' => 0, 'force' => false, 'queue_dirty' => true,
				'request_duration_ms' => 0, 'parse_duration_ms' => 0, 'queue_duration_ms' => 0,
				'incremental_request_duration_ms' => 0, 'incremental_parse_duration_ms' => 0,
				'incremental_fetched' => 0, 'incremental_fallback' => 0, 'incremental_retry_pending' => 0,
				'incremental_fallback_counts' => array(),
				'fallback_counts' => array(), 'scheduled_at' => '', 'scheduled_delay' => 0,
				'started_at' => '', 'updated_at' => '', 'completed_at' => '', 'last_error' => '',
				'last_runner' => '', 'last_batch_at' => '', 'last_batch_records' => 0,
				'last_batch_duration_ms' => 0, 'last_batch_pages_per_minute' => 0,
			)
		);
		$state['remaining'] = max( 0, (int) $state['total'] - (int) $state['fetched'] - (int) $state['fallback'] );
		return $state;
	}

	/** Format one persisted verification result for the dashboard. */
	public static function format_result( array $verification ) {
		$reason           = isset( $verification['reason'] ) ? trim( (string) $verification['reason'] ) : '';
		$source = isset( $verification['input_source'] ) ? $verification['input_source'] : '';
		if ( 'rendered' === $source || ! empty( $verification['used_for_analysis'] ) ) {
			$reason = __( 'Rendered page content was used for this analysis.', 'content-miner' );
		} elseif ( 'stored_nonpublic' === $source ) {
			$reason = __( 'Stored content was used because this record is not public.', 'content-miner' );
		}
		$stored_words     = isset( $verification['stored_words'] ) ? (int) $verification['stored_words'] : 0;
		$rendered_words   = isset( $verification['rendered_words'] ) ? (int) $verification['rendered_words'] : 0;
		$has_count_fields = array_key_exists( 'stored_words', $verification )
			&& array_key_exists( 'rendered_words', $verification );
		$rendered_words_available = array_key_exists( 'rendered_words_available', $verification )
			? (bool) $verification['rendered_words_available']
			: ( 0 < $rendered_words || '' === $reason );
		$has_usable_count = $has_count_fields && $rendered_words_available;

		$result = $reason;
		if ( $has_usable_count ) {
			/* translators: 1: stored word count, 2: rendered word count. */
			$counts = sprintf( __( '%1$d stored words · %2$d rendered words', 'content-miner' ), $stored_words, $rendered_words );
			$result = '' === $reason ? $counts : $reason . ' ' . $counts;
		}

		$stripped = isset( $verification['stripped_elements'] ) && is_array( $verification['stripped_elements'] )
			? $verification['stripped_elements']
			: array();
		$details = array();
		foreach ( self::STRIPPED_ELEMENTS as $element ) {
			if ( ! isset( $stripped[ $element ] ) || ! is_array( $stripped[ $element ] ) || ! array_key_exists( 'words', $stripped[ $element ] ) ) {
				continue;
			}
			/* translators: 1: HTML element name, 2: removed word count. */
			$details[] = sprintf( __( '%1$s (%2$d words)', 'content-miner' ), $element, (int) $stripped[ $element ]['words'] );
		}
		if ( $details ) {
			/* translators: %s: comma-separated HTML element names and removed word counts. */
			$removed = sprintf( __( 'Removed from rendered content: %s.', 'content-miner' ), implode( ', ', $details ) );
			$result = '' === $result ? $removed : $result . ' ' . $removed;
		}

		return $result;
	}

	/** Fetch, parse, and persist one canonical snapshot attempt. */
	private function snapshot_item( array $item, $attempt, array &$state ) {
		$fetch = $this->fetch( $item['url'] );
		$state['request_duration_ms'] += isset( $fetch['request_duration_ms'] ) ? (int) $fetch['request_duration_ms'] : 0;
		$state['attempts'] += 1;
		$memory = get_post_meta( (int) $item['id'], Content_Miner_Memory::META_KEY, true );
		$memory = is_array( $memory ) ? $memory : array();
		$stored_content = isset( $item['stored_content'] ) ? $item['stored_content'] : ( isset( $item['content'] ) ? $item['content'] : '' );
		$stored_words = Content_Miner_Rendered_Snapshot::word_count( $stored_content );
		if ( empty( $fetch['ok'] ) ) {
			$retryable = ! empty( $fetch['retryable'] ) && (int) $attempt < self::MAX_ATTEMPTS;
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, $fetch['reason_code'], $fetch, $attempt, $retryable );
			return array( 'status' => 'fallback', 'reason_code' => $fetch['reason_code'], 'retryable' => $retryable );
		}

		$body = wp_remote_retrieve_body( $fetch['response'] );
		if ( strlen( $body ) > self::MAX_RENDERED_HTML_BYTES ) {
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, 'response_too_large', $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => 'response_too_large', 'retryable' => false );
		}
		if ( preg_match( '/(?:name=["\']post_password["\']|wp-postpass|post-password-form)/i', $body ) ) {
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, 'password_protected', $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => 'password_protected', 'retryable' => false );
		}

		$extracted = Content_Miner_Rendered_Snapshot::extract( $body, $item['url'] );
		$state['parse_duration_ms'] += isset( $extracted['parse_duration_ms'] ) ? (int) $extracted['parse_duration_ms'] : 0;
		if ( empty( $extracted['ok'] ) ) {
			$reason = isset( $extracted['reason_code'] ) ? $extracted['reason_code'] : 'parse_failed';
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, $reason, $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => $reason, 'retryable' => false );
		}
		$rendered_words = (int) $extracted['word_count'];
		if ( 0 === $rendered_words ) {
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, 'rendered_empty', $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => 'rendered_empty', 'retryable' => false );
		}
		if ( Content_Miner_Rendered_Snapshot::near_empty_mismatch( $rendered_words, $stored_words ) ) {
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, 'rendered_near_empty_mismatch', $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => 'rendered_near_empty_mismatch', 'retryable' => false );
		}
		if ( (int) $extracted['bytes'] > self::MAX_RENDERED_TEXT_BYTES ) {
			$this->persist_fallback( $item, $memory, $stored_content, $stored_words, 'response_too_large', $fetch, $attempt, false );
			return array( 'status' => 'fallback', 'reason_code' => 'response_too_large', 'retryable' => false );
		}

		$verification = $this->snapshot_base( $item, $memory, $stored_content, $stored_words, $fetch, $attempt );
		$verification = array_merge(
			$verification,
			array(
				'status'                   => 'rendered',
				'input_source'             => 'rendered',
				'reason'                   => '',
				'fallback_reason_code'     => '',
				'rendered_words'           => $rendered_words,
				'rendered_words_available' => true,
				'rendered_text_available'  => true,
				'rendered_text_hash'       => hash( 'sha256', $extracted['text'] ),
				'rendered_content_hash'    => $extracted['content_hash'],
				'rendered_text_truncated'  => false,
				'rendered_text_bytes'      => (int) $extracted['bytes'],
				'analysis_title'           => $extracted['analysis_title'],
				'published_at'             => $extracted['published_at'],
				'modified_at'              => $extracted['modified_at'],
				'signals'                  => $extracted['signals'],
				'stripped_elements'        => $extracted['stripped_elements'],
				'stripped_words'           => (int) $extracted['stripped_words'],
				'parse_duration_ms'        => (int) $extracted['parse_duration_ms'],
			)
		);
		update_post_meta( (int) $item['id'], self::TEXT_META_KEY, wp_slash( $extracted['text'] ) );
		$memory['verification'] = $verification;
		update_post_meta( (int) $item['id'], Content_Miner_Memory::META_KEY, $memory );
		return array( 'status' => 'rendered', 'reason_code' => '', 'retryable' => false );
	}

	/** One network attempt with same-origin redirects and no immediate retry. */
	private function fetch( $url ) {
		$origin = $this->origin( $url );
		if ( '' === $origin ) {
			return array( 'ok' => false, 'reason_code' => 'loopback_error', 'retryable' => false, 'http_status' => 0, 'request_duration_ms' => 0 );
		}
		$current = $url;
		$request_duration_ms = 0;
		for ( $redirects = 0; $redirects <= 3; ++$redirects ) {
			$this->throttle();
			$args = array(
				'timeout' => self::REQUEST_TIMEOUT,
				'redirection' => 0,
				'sslverify' => (bool) apply_filters( 'content_miner_verification_sslverify', true, $current ),
				'limit_response_size' => self::MAX_RENDERED_HTML_BYTES + 1,
			);
			$request_started = $this->now();
			$response = wp_remote_get( $current, $args );
			$request_duration_ms += (int) round( max( 0, $this->now() - $request_started ) * 1000 );
			if ( is_wp_error( $response ) ) {
				$message = strtolower( $response->get_error_message() );
				$reason = false !== strpos( $message, 'timed out' ) || false !== strpos( $message, 'timeout' ) ? 'timeout' : 'loopback_error';
				return array( 'ok' => false, 'reason_code' => $reason, 'retryable' => true, 'http_status' => 0, 'request_duration_ms' => $request_duration_ms );
			}
			$status = (int) wp_remote_retrieve_response_code( $response );
			if ( $status >= 300 && $status < 400 ) {
				$location = (string) wp_remote_retrieve_header( $response, 'location' );
				$next = $this->resolve_redirect( $location, $current );
				if ( '' === $next || $this->origin( $next ) !== $origin ) {
					return array( 'ok' => false, 'reason_code' => 'redirect_off_origin', 'retryable' => false, 'http_status' => $status, 'request_duration_ms' => $request_duration_ms );
				}
				$current = $next;
				continue;
			}
			if ( $status < 200 || $status >= 400 ) {
				$reason = 401 === $status ? 'http_unauthorized' : ( 403 === $status ? 'http_forbidden' : ( 404 === $status ? 'http_not_found' : 'http_error' ) );
				return array( 'ok' => false, 'reason_code' => $reason, 'retryable' => 429 === $status || $status >= 500, 'http_status' => $status, 'request_duration_ms' => $request_duration_ms );
			}
			return array(
				'ok' => true,
				'response' => $response,
				'http_status' => $status,
				'response_etag' => (string) wp_remote_retrieve_header( $response, 'etag' ),
				'response_last_modified' => (string) wp_remote_retrieve_header( $response, 'last-modified' ),
				'response_age' => (string) wp_remote_retrieve_header( $response, 'age' ),
				'request_duration_ms' => $request_duration_ms,
			);
		}
		return array( 'ok' => false, 'reason_code' => 'http_error', 'retryable' => false, 'http_status' => 0, 'request_duration_ms' => $request_duration_ms );
	}

	/** Backward-compatible public parser entry point. */
	public static function extract_rendered_content( $html, $page_url = '' ) {
		return Content_Miner_Rendered_Snapshot::extract( $html, $page_url );
	}

	private function complete( array $state ) {
		$state['status'] = 'complete';
		$state['phase'] = 'complete';
		$state['processed'] = (int) $state['fetched'] + (int) $state['fallback'];
		$state['remaining'] = 0;
		$state['flagged'] = (int) $state['fallback'];
		$state['updated_at'] = gmdate( 'c' );
		$state['completed_at'] = gmdate( 'c' );
		$state['scheduled_at'] = '';
		$state['scheduled_delay'] = 0;
		update_option( self::STATE_OPTION, $state, false );
		return $state;
	}

	private function count_fallback( array &$state, $reason_code ) {
		++$state['fallback'];
		++$state['processed'];
		$state['flagged'] = $state['fallback'];
		$reason_code = sanitize_key( $reason_code );
		$state['fallback_counts'][ $reason_code ] = isset( $state['fallback_counts'][ $reason_code ] )
			? (int) $state['fallback_counts'][ $reason_code ] + 1
			: 1;
	}

	private function snapshot_base( array $item, array $memory, $stored_content, $stored_words, array $fetch, $attempt ) {
		return array(
			'snapshot_schema'          => Content_Miner_Rendered_Snapshot::SCHEMA_VERSION,
			'parser_version'           => Content_Miner_Rendered_Snapshot::PARSER_VERSION,
			'stored_words'             => (int) $stored_words,
			'stored_content_hash'      => Content_Miner_Rendered_Snapshot::content_hash( $stored_content ),
			'source_fingerprint'       => Content_Miner_Rendered_Snapshot::source_fingerprint( $stored_content, $item['url'], $item['status'], $item['source_modified_at'] ),
			'source_modified_at'       => (string) $item['source_modified_at'],
			'source_status'            => (string) $item['status'],
			'memory_content_hash'      => isset( $memory['content_hash'] ) ? (string) $memory['content_hash'] : '',
			'http_status'              => isset( $fetch['http_status'] ) ? (int) $fetch['http_status'] : 0,
			'response_etag'            => isset( $fetch['response_etag'] ) ? sanitize_text_field( $fetch['response_etag'] ) : '',
			'response_last_modified'   => isset( $fetch['response_last_modified'] ) ? sanitize_text_field( $fetch['response_last_modified'] ) : '',
			'response_age'             => isset( $fetch['response_age'] ) ? sanitize_text_field( $fetch['response_age'] ) : '',
			'attempts'                 => (int) $attempt,
			'checked_at'               => gmdate( 'c' ),
		);
	}

	private function persist_fallback( array $item, array $memory, $stored_content, $stored_words, $reason_code, array $fetch, $attempt, $retry_pending ) {
		$verification = array_merge(
			$this->snapshot_base( $item, $memory, $stored_content, $stored_words, $fetch, $attempt ),
			array(
				'status'                   => $retry_pending ? 'retry_pending' : 'fallback',
				'input_source'             => 'stored_fallback',
				'reason'                   => self::fallback_reason( $reason_code ),
				'fallback_reason_code'     => sanitize_key( $reason_code ),
				'rendered_words'           => 0,
				'rendered_words_available' => false,
				'rendered_text_available'  => false,
				'rendered_text_hash'       => '',
				'rendered_content_hash'    => '',
				'rendered_text_truncated'  => 'response_too_large' === $reason_code,
				'rendered_text_bytes'      => 0,
				'signals'                  => array(),
				'stripped_elements'        => array(),
				'stripped_words'           => 0,
				'retry_after'              => $retry_pending ? time() + 60 : 0,
			)
		);
		delete_post_meta( (int) $item['id'], self::TEXT_META_KEY );
		$memory['verification'] = $verification;
		update_post_meta( (int) $item['id'], Content_Miner_Memory::META_KEY, $memory );
	}

	private static function fallback_reason( $reason_code ) {
		$reasons = array(
			'loopback_error' => __( 'The public page could not be fetched from this host.', 'content-miner' ),
			'timeout' => __( 'The public-page request timed out.', 'content-miner' ),
			'http_unauthorized' => __( 'The public page requires authentication.', 'content-miner' ),
			'http_forbidden' => __( 'The host refused the public-page request.', 'content-miner' ),
			'http_not_found' => __( 'The public page was not found.', 'content-miner' ),
			'http_error' => __( 'The public page returned an unsuccessful response.', 'content-miner' ),
			'redirect_off_origin' => __( 'The public page redirected away from this site.', 'content-miner' ),
			'password_protected' => __( 'The public page is password protected.', 'content-miner' ),
			'response_too_large' => __( 'The rendered response is too large to process safely.', 'content-miner' ),
			'dom_unavailable' => __( 'The server cannot parse rendered HTML safely.', 'content-miner' ),
			'parse_failed' => __( 'The fetched public page could not be parsed safely.', 'content-miner' ),
			'rendered_empty' => __( 'The fetched public page contained no usable rendered text.', 'content-miner' ),
			'rendered_near_empty_mismatch' => __( 'The fetched page was unexpectedly empty compared with its stored record.', 'content-miner' ),
		);
		return isset( $reasons[ $reason_code ] ) ? $reasons[ $reason_code ] : __( 'Rendered input was unavailable, so stored content was used.', 'content-miner' );
	}

	private function queue_dirty( array $post_ids ) {
		if ( empty( $post_ids ) || ! $this->memory ) {
			return;
		}
		if ( method_exists( $this->memory, 'queue_dirty_posts' ) ) {
			$this->memory->queue_dirty_posts( $post_ids );
		} elseif ( method_exists( $this->memory, 'queue_dirty_post' ) ) {
			foreach ( $post_ids as $post_id ) {
				$this->memory->queue_dirty_post( $post_id );
			}
		}
	}

	private function checkpoint_and_schedule( array $state, $delay ) {
		$delay = max( 1, (int) $delay );
		$state['scheduled_at'] = gmdate( 'c' );
		$state['scheduled_delay'] = $delay;
		$state['remaining'] = max( 0, (int) $state['total'] - (int) $state['fetched'] - (int) $state['fallback'] );
		update_option( self::STATE_OPTION, $state, false );
		$this->schedule_next( $delay );
		return $state;
	}

	private function record_queue_duration( array &$state ) {
		if ( empty( $state['scheduled_at'] ) ) {
			return;
		}
		$scheduled = strtotime( (string) $state['scheduled_at'] );
		if ( false !== $scheduled ) {
			$state['queue_duration_ms'] += max( 0, time() - $scheduled ) * 1000;
		}
		$state['scheduled_at'] = '';
		$state['scheduled_delay'] = 0;
	}

	private function record_batch_metrics( array &$state, $runner, $started_at, $work_before ) {
		$duration_ms = max( 1, (int) round( ( microtime( true ) - (float) $started_at ) * 1000 ) );
		$records = max( 0, (int) $state['fetched'] + (int) $state['fallback'] - (int) $work_before );
		$state['last_runner'] = $this->normalize_runner( $runner );
		$state['last_batch_at'] = gmdate( 'c' );
		$state['last_batch_records'] = $records;
		$state['last_batch_duration_ms'] = $duration_ms;
		$state['last_batch_pages_per_minute'] = $records > 0 ? round( $records / ( $duration_ms / 60000 ), 1 ) : 0;
	}

	private function normalize_runner( $runner ) {
		$runner = preg_replace( '/[^a-z0-9_-]/', '', strtolower( (string) $runner ) );
		return '' !== $runner ? $runner : 'background';
	}

	private function throttle() {
		$now = $this->now();
		$wait = 0.5 - ( $now - $this->last_request_at );
		if ( $this->last_request_at > 0 && $wait > 0 ) {
			call_user_func( $this->sleeper, (int) ceil( $wait * 1000000 ) );
		}
		$this->last_request_at = $this->now();
	}

	private function now() {
		return (float) call_user_func( $this->clock );
	}

	private function origin( $url ) {
		$scheme = strtolower( (string) wp_parse_url( (string) $url, PHP_URL_SCHEME ) );
		$host = strtolower( (string) wp_parse_url( (string) $url, PHP_URL_HOST ) );
		$port = wp_parse_url( (string) $url, PHP_URL_PORT );
		return in_array( $scheme, array( 'http', 'https' ), true ) && '' !== $host
			? $scheme . '://' . $host . ( $port ? ':' . (int) $port : '' )
			: '';
	}

	private function resolve_redirect( $location, $current ) {
		$location = trim( (string) $location );
		if ( '' === $location ) {
			return '';
		}
		if ( preg_match( '#^https?://#i', $location ) ) {
			return $location;
		}
		$origin = $this->origin( $current );
		if ( 0 === strpos( $location, '/' ) ) {
			return $origin . $location;
		}
		$path = (string) wp_parse_url( $current, PHP_URL_PATH );
		return $origin . preg_replace( '#/[^/]*$#', '/', $path ? $path : '/' ) . $location;
	}

	public static function bump_render_epoch() {
		update_option( Content_Miner_Rendered_Snapshot::RENDER_EPOCH_OPTION, (int) get_option( Content_Miner_Rendered_Snapshot::RENDER_EPOCH_OPTION, 1 ) + 1, false );
	}

	private function acquire_lock( $job_id ) {
		$payload = array( 'job_id' => (string) $job_id, 'locked_at' => time() );
		if ( add_option( self::LOCK_KEY, $payload, '', false ) ) {
			return true;
		}
		$existing = get_option( self::LOCK_KEY, array() );
		if ( ! is_array( $existing ) || empty( $existing['locked_at'] ) || time() - (int) $existing['locked_at'] <= 2 * MINUTE_IN_SECONDS ) {
			return false;
		}
		delete_option( self::LOCK_KEY );
		return add_option( self::LOCK_KEY, $payload, '', false );
	}

	private function schedule_next( $delay ) {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_single_event( time() + max( 1, (int) $delay ), self::CRON_HOOK );
		}
	}
}
