<?php

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

require_once __DIR__ . '/class-content-miner-analysis-content.php';
require_once __DIR__ . '/class-content-miner-prompt-page-evidence.php';
require_once __DIR__ . '/class-content-miner-scoring-exclusions.php';

/**
 * Persistent, compact memory of previously observed WordPress content.
 */
class Content_Miner_Memory {
	const META_KEY             = '_content_miner_memory';
	const SUMMARY_OPTION       = 'content_miner_memory_summary';
	const HASH_INDEX_OPTION    = 'content_miner_hash_index';
	const RECENT_OPTION        = 'content_miner_recent_changes';
	const DIRTY_QUEUE_OPTION   = 'content_miner_dirty_queue';
	const MEMORY_SCHEMA        = 12;
	const MAX_RECENT_CHANGES   = 30;
	const MAX_DIRTY_QUEUE      = 25000;

	/** @var Content_Miner_Family_Classifier */
	private $classifier;

	/** @var Content_Miner_Readiness_Analyzer */
	private $readiness;

	/** @var object|null */
	private $vector_store;

	/** @var Content_Miner_Analyzer|null */
	private $analyzer;

	/**
	 * @param Content_Miner_Family_Classifier $classifier Classifier.
	 * @param Content_Miner_Readiness_Analyzer $readiness Readiness analyzer.
	 */
	public function __construct( $classifier, $readiness, $vector_store = null, $analyzer = null ) {
		$this->classifier = $classifier;
		$this->readiness  = $readiness;
		$this->vector_store = $vector_store;
		$this->analyzer = $analyzer ? $analyzer : ( class_exists( 'Content_Miner_Analyzer' ) ? new Content_Miner_Analyzer() : null );
	}

	/**
	 * Remember a bounded batch and update compact aggregate indexes once.
	 *
	 * @param array<int,array<string,mixed>> $items Content items.
	 * @param bool                           $reindex Rebuild aggregates even when fingerprints match.
	 * @return array<string,mixed>
	 */
	public function remember_items( array $items, $reindex = false ) {
		$summary    = $this->get_summary();
		$hash_index = get_option( self::HASH_INDEX_OPTION, array() );
		$recent     = get_option( self::RECENT_OPTION, array() );
		$changed    = 0;
		$skipped    = 0;
		$vector_items = array();
		$scoring_population_changed = false;
		$analysis_selection_changed = false;
		$analysis_result_changed = false;

		if ( ! is_array( $hash_index ) ) {
			$hash_index = array();
		}
		if ( ! is_array( $recent ) ) {
			$recent = array();
		}

		foreach ( $items as $item ) {
			$post_id = (int) $item['id'];
			$old     = get_post_meta( $post_id, self::META_KEY, true );
			$old     = is_array( $old ) ? $old : array();
			$analysis_content = Content_Miner_Analysis_Content::from_item( $item );
			$memory  = $this->build_memory( $item, $analysis_content );
			$verification = isset( $item['verification'] ) && is_array( $item['verification'] ) ? $item['verification'] : array();
			$stored_content = isset( $item['stored_content'] ) ? $item['stored_content'] : '';
			$verification_current = $verification
				&& class_exists( 'Content_Miner_Rendered_Snapshot' )
				&& Content_Miner_Rendered_Snapshot::is_current(
					$verification,
					$stored_content,
					isset( $item['url'] ) ? $item['url'] : '',
					isset( $item['status'] ) ? $item['status'] : '',
					isset( $item['source_modified_at'] ) ? $item['source_modified_at'] : ( isset( $item['modified_at'] ) ? $item['modified_at'] : '' )
				);
			$legacy_verification = $verification
				&& ! array_key_exists( 'stored_content_hash', $verification )
				&& ! array_key_exists( 'source_modified_at', $verification )
				&& ! array_key_exists( 'source_status', $verification );
			// Count-only 1.3.8 verification may predate content memory entirely.
			// It never selects a rendered body, so retaining it preserves only the
			// existing conservative qualification until an operator verifies again.
			if ( $verification_current || $legacy_verification ) {
				$memory['verification'] = $verification;
			} elseif ( $verification && ! $legacy_verification && class_exists( 'Content_Miner_Rendered_Verification' ) ) {
				// A source edit invalidates the separately stored rendered snapshot.
				delete_post_meta( $post_id, Content_Miner_Rendered_Verification::TEXT_META_KEY );
			}
			$old_input_source = isset( $old['verification']['input_source'] ) ? $old['verification']['input_source'] : '';
			$new_input_source = isset( $item['analysis_content_source'] ) ? $item['analysis_content_source'] : 'stored_fallback';
			if ( 'rendered' === $old_input_source && 'rendered' !== $new_input_source && class_exists( 'Content_Miner_Rendered_Verification' ) ) {
				// Do not retain a rendered body after its page becomes non-public or
				// the canonical selection moves to an explicit stored fallback.
				delete_post_meta( $post_id, Content_Miner_Rendered_Verification::TEXT_META_KEY );
			}
			$vector_items[] = array(
				'post_id'      => $post_id,
				'content_hash' => $analysis_content['hash'],
				'text'         => $analysis_content['payload'],
			);
			$old_scored = $this->is_in_scope( $old );
			$new_scored = $this->is_in_scope( $memory );
			$old_active = ! empty( $old['active'] );
			$new_active = ! empty( $memory['active'] );
			$scoring_state_changed = empty( $old )
				|| $old_scored !== $new_scored
				|| $old_active !== $new_active
				|| wp_json_encode( isset( $old['scoring'] ) ? $old['scoring'] : array() ) !== wp_json_encode( $memory['scoring'] );
			if ( $scoring_state_changed ) {
				$scoring_population_changed = true;
			}
			$item_analysis_selection_changed = empty( $old )
				|| ! isset( $old['schema'] )
				|| self::MEMORY_SCHEMA !== (int) $old['schema']
				|| ! isset( $old['analysis_content_hash'] )
				|| ! hash_equals( (string) $old['analysis_content_hash'], $analysis_content['hash'] )
				|| ! isset( $old['analysis_content_source'] )
				|| $old['analysis_content_source'] !== $analysis_content['source'];
			if ( $item_analysis_selection_changed ) {
				$analysis_selection_changed = true;
			}
			$source_changed = empty( $old )
				|| $scoring_state_changed
				|| $item_analysis_selection_changed
				|| ! isset( $old['content_hash'] )
				|| $old['content_hash'] !== $memory['content_hash']
				|| ! isset( $old['title'] )
				|| $old['title'] !== $memory['title']
				|| ! isset( $old['status'] )
				|| $old['status'] !== $memory['status']
				|| ! isset( $old['modified_at'] )
				|| $old['modified_at'] !== $memory['modified_at'];
			if ( $source_changed ) {
				// Full analysis sees the complete body even when the bounded
				// embedding payload is unchanged.
				$analysis_result_changed = true;
			}

			if ( isset( $old['fingerprint'] ) && hash_equals( $old['fingerprint'], $memory['fingerprint'] ) ) {
				if ( $reindex ) {
					$this->update_summary_counts( $summary, array(), $memory );
					$this->update_hash_index( $hash_index, $post_id, array(), $memory );
				}
				++$skipped;
				continue;
			}

			$aggregate_old = $reindex ? array() : $old;
			$this->update_summary_counts( $summary, $aggregate_old, $memory );
			$this->update_hash_index( $hash_index, $post_id, $aggregate_old, $memory );
			update_post_meta( $post_id, self::META_KEY, $memory );

			if ( $source_changed ) {
				array_unshift(
					$recent,
					array(
						'post_id'     => $post_id,
						'title'       => $memory['title'],
						'edit_url'    => $item['edit_url'],
						'family'      => $memory['family'],
						'change_type' => empty( $old ) ? 'added' : ( $memory['active'] ? 'updated' : 'removed' ),
						'observed_at' => $memory['observed_at'],
					)
				);
				++$changed;
			} else {
				++$skipped;
			}
		}

		$recent = array_slice( $recent, 0, self::MAX_RECENT_CHANGES );
		$this->update_duplicate_summary( $summary, $hash_index );
		$summary['last_synced_at'] = gmdate( 'c' );

		update_option( self::SUMMARY_OPTION, $summary, false );
		update_option( self::HASH_INDEX_OPTION, $hash_index, false );
		update_option( self::RECENT_OPTION, $recent, false );

		if ( $scoring_population_changed || $analysis_selection_changed || $analysis_result_changed ) {
			// These are derived from the scored population and selected analysis body.
			delete_option( 'content_miner_latest_scan' );
			delete_option( 'content_miner_analysis_state' );
		}

		return array(
			'changed' => $changed,
			'skipped' => $skipped,
			'vector_items' => $vector_items,
		);
	}

	/** Store one completed page decision inside content memory. */
	public function update_analysis( $post_id, array $prepared ) {
		$memory = get_post_meta( (int) $post_id, self::META_KEY, true );
		if ( ! is_array( $memory ) || empty( $memory ) ) {
			return false;
		}
		if ( isset( $memory['verification'] ) && is_array( $memory['verification'] ) ) {
			$memory['verification']['used_for_analysis'] = 'rendered' === ( isset( $prepared['analysis_content_source'] ) ? $prepared['analysis_content_source'] : 'stored_fallback' );
		}
		$memory['analysis'] = array(
			'recommendation' => $prepared['recommendation'],
			'overlap_count'  => isset( $prepared['overlap_count'] ) ? (int) $prepared['overlap_count'] : 0,
			'age_days'       => isset( $prepared['age_days'] ) ? (int) $prepared['age_days'] : 0,
			'data_confidence'=> isset( $prepared['data_confidence'] ) ? $prepared['data_confidence'] : 'Content only',
			'content_source' => isset( $prepared['analysis_content_source'] ) ? $prepared['analysis_content_source'] : 'stored_fallback',
			'analyzed_at'    => gmdate( 'c' ),
		);
		return (bool) update_post_meta( (int) $post_id, self::META_KEY, $memory );
	}

	/** Read one dashboard page directly from per-post memory. @return array<string,mixed> */
	public function get_analysis_page( $page = 1, $per_page = 25, $search = '', $max_post_id = PHP_INT_MAX ) {
		global $wpdb;
		$page = max( 1, (int) $page );
		$per_page = min( 100, max( 1, (int) $per_page ) );
		$offset = ( $page - 1 ) * $per_page;
		$where = 'pm.meta_key = %s AND pm.meta_value LIKE %s';
		$args = array( self::META_KEY, '%s:6:"active";b:1%' );
		if ( PHP_INT_MAX !== (int) $max_post_id ) {
			$where .= ' AND p.ID <= %d';
			$args[] = max( 0, (int) $max_post_id );
		}
		if ( '' !== trim( $search ) ) {
			$like = '%' . $wpdb->esc_like( trim( $search ) ) . '%';
			$where .= ' AND (p.post_title LIKE %s OR p.post_type LIKE %s OR p.post_status LIKE %s OR pm.meta_value LIKE %s)';
			array_push( $args, $like, $like, $like, $like );
		}
		$count_sql = "SELECT COUNT(*) FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID WHERE {$where}";
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names and the WHERE fragment are internally generated; all values use placeholders.
		$total = (int) $wpdb->get_var( $wpdb->prepare( $count_sql, ...$args ) );
		$row_sql = "SELECT p.ID, p.post_title, p.post_type, p.post_status, pm.meta_value FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID WHERE {$where} ORDER BY p.post_modified_gmt DESC, p.ID DESC LIMIT %d OFFSET %d";
		$row_args = array_merge( $args, array( $per_page, $offset ) );
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table names and the WHERE fragment are internally generated; all values use placeholders.
		$rows = $wpdb->get_results( $wpdb->prepare( $row_sql, ...$row_args ), ARRAY_A );
		$items = array();
		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
			$memory = maybe_unserialize( $row['meta_value'] );
			if ( ! is_array( $memory ) || empty( $memory['active'] ) ) {
				continue;
			}
			$analysis = isset( $memory['analysis'] ) && is_array( $memory['analysis'] ) ? $memory['analysis'] : array();
			$analysis_current = ! empty( $analysis['recommendation'] ) && is_array( $analysis['recommendation'] );
			$analysis_source = isset( $analysis['content_source'] ) ? $analysis['content_source'] : ( isset( $memory['analysis_content_source'] ) ? $memory['analysis_content_source'] : 'stored_fallback' );
			$recommendation = $analysis_current
				? $analysis['recommendation']
				: array(
					'action'     => $this->is_in_scope( $memory ) ? __( 'Pending', 'content-miner' ) : __( 'Not scored', 'content-miner' ),
					'confidence' => '',
					'reason'     => $this->is_in_scope( $memory ) ? __( 'Refresh the full analysis for a current recommendation.', 'content-miner' ) : __( 'This page is excluded from site scoring.', 'content-miner' ),
				);
			if ( $analysis_current && $this->analyzer && method_exists( $this->analyzer, 'qualify_recommendation' ) ) {
				$recommendation = $this->analyzer->qualify_recommendation(
					$recommendation,
					isset( $memory['verification'] ) && is_array( $memory['verification'] ) ? $memory['verification'] : array(),
					$analysis_source
				);
			}
			if ( 'rendered' === $analysis_source ) {
				$memory['extraction_warnings'] = array();
			}
			$items[] = array_merge(
				$memory,
				array(
					'id'              => (int) $row['ID'],
					'title'           => $row['post_title'],
					'post_type'       => $row['post_type'],
					'status'          => $row['post_status'],
					'view_url'        => Content_Miner_Repository::get_post_view_url( (int) $row['ID'] ),
					'edit_url'        => get_edit_post_link( (int) $row['ID'], 'raw' ),
					'recommendation'  => $recommendation,
					'overlap_count'   => isset( $analysis['overlap_count'] ) ? (int) $analysis['overlap_count'] : 0,
					'age_days'        => $analysis_current && isset( $analysis['age_days'] ) ? (int) $analysis['age_days'] : null,
					'data_confidence' => isset( $analysis['data_confidence'] ) ? $analysis['data_confidence'] : __( 'Content only', 'content-miner' ),
					'analysis_current' => $analysis_current,
					'analysis_content_source' => $analysis_source,
				)
			);
		}
		return array( 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $per_page );
	}

	/**
	 * Select a globally ranked, body-free page subset for whole-report prompts.
	 *
	 * Memory is read in bounded chunks so prompt generation does not load a
	 * large site's full inventory at once. Only the best 20 candidates survive
	 * each chunk; public URL paths are resolved after ranking.
	 *
	 * @param array<string,mixed> $search_status Current Search Console status.
	 * @param int                 $max_post_id Highest post ID in the report snapshot.
	 * @return array<int,array<string,mixed>>
	 */
	public function get_prompt_pages( array $search_status, $max_post_id = PHP_INT_MAX ) {
		global $wpdb;
		$cursor = 0;
		$batch_size = 500;
		$ranked = array();
		$issue_labels = $this->readiness->get_issue_labels();
		$search_context = array(
			'available'   => ! empty( $search_status['connected'] )
				&& ! empty( $search_status['last_sync_at'] )
				&& 'complete' === (string) ( $search_status['sync_status'] ?? '' )
				&& ! empty( $search_status['property'] )
				&& ! empty( $search_status['sync_run_id'] )
				&& ! empty( $search_status['sync_start_date'] )
				&& ! empty( $search_status['sync_end_date'] ),
			'property'    => (string) ( $search_status['property'] ?? '' ),
			'sync_run_id' => (string) ( $search_status['sync_run_id'] ?? '' ),
			'start_date'  => (string) ( $search_status['sync_start_date'] ?? '' ),
			'end_date'    => (string) ( $search_status['sync_end_date'] ?? '' ),
		);

		do {
			if ( $search_context['available'] ) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only bounded report projection.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT memory.post_id, memory.meta_value AS memory_value, search.meta_value AS search_value
						FROM {$wpdb->postmeta} memory
						INNER JOIN (
							SELECT post_id, MAX(meta_id) AS meta_id
							FROM {$wpdb->postmeta}
							WHERE meta_key = %s
								AND post_id > %d
								AND post_id <= %d
							GROUP BY post_id
							ORDER BY post_id ASC
							LIMIT %d
						) latest_memory ON latest_memory.meta_id = memory.meta_id
						LEFT JOIN {$wpdb->postmeta} search
							ON search.meta_id = (
								SELECT MAX(latest_search.meta_id)
								FROM {$wpdb->postmeta} latest_search
								WHERE latest_search.post_id = memory.post_id
									AND latest_search.meta_key = %s
							)
						ORDER BY memory.post_id ASC",
						self::META_KEY,
						$cursor,
						max( 0, (int) $max_post_id ),
						$batch_size,
						'_content_miner_search_console'
					),
					ARRAY_A
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only bounded report projection.
				$rows = $wpdb->get_results(
					$wpdb->prepare(
						"SELECT memory.post_id, memory.meta_value AS memory_value, NULL AS search_value
						FROM {$wpdb->postmeta} memory
						INNER JOIN (
							SELECT post_id, MAX(meta_id) AS meta_id
							FROM {$wpdb->postmeta}
							WHERE meta_key = %s
								AND post_id > %d
								AND post_id <= %d
							GROUP BY post_id
							ORDER BY post_id ASC
							LIMIT %d
						) latest_memory ON latest_memory.meta_id = memory.meta_id
						ORDER BY memory.post_id ASC",
						self::META_KEY,
						$cursor,
						max( 0, (int) $max_post_id ),
						$batch_size
					),
					ARRAY_A
				);
			}
			$rows = is_array( $rows ) ? $rows : array();
			foreach ( $rows as $row ) {
				$post_id = (int) $row['post_id'];
				$cursor = max( $cursor, $post_id );
				$memory = maybe_unserialize( $row['memory_value'] );
				if ( ! is_array( $memory ) || empty( $memory['active'] ) || ! $this->is_in_scope( $memory ) ) {
					continue;
				}
				$search = maybe_unserialize( $row['search_value'] );
				$ranked[] = Content_Miner_Prompt_Page_Evidence::from_memory(
					$post_id,
					$memory,
					is_array( $search ) ? $search : array(),
					$issue_labels,
					$search_context
				);
			}
			$ranked = Content_Miner_Prompt_Page_Evidence::rank(
				$ranked,
				(bool) $search_context['available']
			);
		} while ( count( $rows ) === $batch_size && $cursor < (int) $max_post_id );

		foreach ( $ranked as &$page ) {
			$page['path'] = Content_Miner_Prompt_Page_Evidence::path_from_url(
				Content_Miner_Repository::get_post_view_url( (int) $page['id'] )
			);
		}
		unset( $page );

		return $ranked;
	}

	/**
	 * Count scored pages that have current Search Console evidence for prompt use.
	 *
	 * This uses the same Search Console identity fields as get_prompt_pages() so
	 * stale page metrics do not hide the post-upgrade sync notice.
	 *
	 * @param array<string,mixed> $search_status Current Search Console status.
	 * @param int                 $max_post_id Highest post ID in the report snapshot.
	 * @return int
	 */
	public function count_prompt_pages_with_current_search( array $search_status, $max_post_id = PHP_INT_MAX ) {
		global $wpdb;
		$search_context = array(
			'available'   => ! empty( $search_status['connected'] )
				&& ! empty( $search_status['last_sync_at'] )
				&& 'complete' === (string) ( $search_status['sync_status'] ?? '' )
				&& ! empty( $search_status['property'] )
				&& ! empty( $search_status['sync_run_id'] )
				&& ! empty( $search_status['sync_start_date'] )
				&& ! empty( $search_status['sync_end_date'] ),
			'property'    => (string) ( $search_status['property'] ?? '' ),
			'sync_run_id' => (string) ( $search_status['sync_run_id'] ?? '' ),
			'start_date'  => (string) ( $search_status['sync_start_date'] ?? '' ),
			'end_date'    => (string) ( $search_status['sync_end_date'] ?? '' ),
		);
		if ( ! $search_context['available'] ) {
			return 0;
		}
		$count = 0;
		$cursor = 0;
		$batch_size = 500;
		$issue_labels = $this->readiness->get_issue_labels();
		do {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only bounded report projection.
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT memory.post_id, memory.meta_value AS memory_value, search.meta_value AS search_value
					FROM {$wpdb->postmeta} memory
					INNER JOIN (
						SELECT post_id, MAX(meta_id) AS meta_id
						FROM {$wpdb->postmeta}
						WHERE meta_key = %s
							AND post_id > %d
							AND post_id <= %d
						GROUP BY post_id
						ORDER BY post_id ASC
						LIMIT %d
					) latest_memory ON latest_memory.meta_id = memory.meta_id
					LEFT JOIN {$wpdb->postmeta} search
						ON search.meta_id = (
							SELECT MAX(latest_search.meta_id)
							FROM {$wpdb->postmeta} latest_search
							WHERE latest_search.post_id = memory.post_id
								AND latest_search.meta_key = %s
						)
					ORDER BY memory.post_id ASC",
					self::META_KEY,
					$cursor,
					max( 0, (int) $max_post_id ),
					$batch_size,
					'_content_miner_search_console'
				),
				ARRAY_A
			);
			$rows = is_array( $rows ) ? $rows : array();
			foreach ( $rows as $row ) {
				$post_id = (int) $row['post_id'];
				$cursor = max( $cursor, $post_id );
				$memory = maybe_unserialize( $row['memory_value'] );
				if ( ! is_array( $memory ) || empty( $memory['active'] ) || ! $this->is_in_scope( $memory ) ) {
					continue;
				}
				$search = maybe_unserialize( $row['search_value'] );
				$page = Content_Miner_Prompt_Page_Evidence::from_memory(
					$post_id,
					$memory,
					is_array( $search ) ? $search : array(),
					$issue_labels,
					$search_context
				);
				if ( ! empty( $page['search_metrics_available'] ) ) {
					++$count;
				}
			}
		} while ( count( $rows ) === $batch_size && $cursor < (int) $max_post_id );
		return $count;
	}

	/** Read one bounded lexical-signature chunk with a single postmeta query. @return array<int,array<string,mixed>> */
	public function get_signature_chunk( $after_id, $limit = 500 ) {
		global $wpdb;
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND post_id > %d ORDER BY post_id ASC LIMIT %d",
				self::META_KEY,
				(int) $after_id,
				min( 500, max( 1, (int) $limit ) )
			),
			ARRAY_A
		);
		$signatures = array();
		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
			$memory = maybe_unserialize( $row['meta_value'] );
			if ( ! is_array( $memory ) ) {
				$memory = array();
			}
			$signatures[] = array(
				'post_id'           => (int) $row['post_id'],
				'active'            => ! empty( $memory['active'] ),
				'scored'            => $this->is_in_scope( $memory ),
				'content_hash'      => isset( $memory['content_hash'] ) ? (string) $memory['content_hash'] : '',
				'analysis_content_hash' => isset( $memory['analysis_content_hash'] ) ? (string) $memory['analysis_content_hash'] : '',
				'lexical_signature' => isset( $memory['lexical_signature'] ) && is_array( $memory['lexical_signature'] ) ? array_values( array_map( 'strval', $memory['lexical_signature'] ) ) : array(),
			);
		}
		return $signatures;
	}

	/**
	 * Queue posts for prompt incremental processing with one bounded option write.
	 *
	 * @param int[] $post_ids Post IDs.
	 */
	public function queue_dirty_posts( array $post_ids ) {
		$queue = get_option( self::DIRTY_QUEUE_OPTION, array() );
		$queue = is_array( $queue ) ? array_values( array_unique( array_map( 'intval', $queue ) ) ) : array();
		$post_ids = array_values( array_filter( array_unique( array_map( 'intval', $post_ids ) ) ) );
		$queue = array_values( array_unique( array_merge( $queue, $post_ids ) ) );

		if ( count( $queue ) > self::MAX_DIRTY_QUEUE ) {
			$summary = $this->get_summary();
			$summary['schema'] = 0;
			update_option( self::SUMMARY_OPTION, $summary, false );
			$queue = array();
		}

		update_option( self::DIRTY_QUEUE_OPTION, $queue, false );
	}

	/**
	 * Queue one post for prompt incremental processing.
	 *
	 * @param int $post_id Post ID.
	 */
	public function queue_dirty_post( $post_id ) {
		$this->queue_dirty_posts( array( (int) $post_id ) );
	}

	/**
	 * Pull and remove the next dirty IDs.
	 *
	 * @param int $limit Maximum IDs.
	 * @return int[]
	 */
	public function pull_dirty_posts( $limit = 25 ) {
		$queue = get_option( self::DIRTY_QUEUE_OPTION, array() );
		$queue = is_array( $queue ) ? array_values( array_unique( array_map( 'intval', $queue ) ) ) : array();
		$next  = array_slice( $queue, 0, $limit );
		$rest  = array_slice( $queue, $limit );

		update_option( self::DIRTY_QUEUE_OPTION, $rest, false );

		return $next;
	}

	/**
	 * @return int
	 */
	public function get_dirty_count() {
		$queue = get_option( self::DIRTY_QUEUE_OPTION, array() );

		return is_array( $queue ) ? count( array_unique( array_map( 'intval', $queue ) ) ) : 0;
	}

	/**
	 * @return array<int,int>
	 */
	public function get_dirty_post_ids() {
		$queue = get_option( self::DIRTY_QUEUE_OPTION, array() );

		return is_array( $queue ) ? array_values( array_unique( array_map( 'intval', $queue ) ) ) : array();
	}

	/**
	 * @return array<string,mixed>
	 */
	public function get_summary() {
		$summary = get_option( self::SUMMARY_OPTION, array() );
		$summary = is_array( $summary ) ? $summary : array();

		return wp_parse_args(
			$summary,
			array(
				'schema'                 => 0,
				'indexed'                => 0,
				'scored'                 => 0,
				'excluded'               => 0,
				'exclusion_reasons'      => array(),
				'families'               => array(),
				'statuses'               => array(),
				'input_sources'          => array(),
				'exact_duplicate_groups' => 0,
				'exact_duplicate_pages'  => 0,
				'readiness_count'        => 0,
				'readiness_sums'         => array( 'seo' => 0, 'aeo' => 0, 'geo' => 0, 'aio' => 0 ),
				'readiness_bands'        => array( 'strong' => 0, 'improve' => 0, 'weak' => 0 ),
				'readiness_issues'       => array(),
				'last_synced_at'         => '',
			)
		);
	}

	/**
	 * @return array<int,array<string,mixed>>
	 */
	public function get_recent_changes() {
		$recent = get_option( self::RECENT_OPTION, array() );

		return is_array( $recent ) ? $recent : array();
	}

	/**
	 * @return array<string,string>
	 */
	public function get_family_labels() {
		return $this->classifier->labels();
	}

	/** Reset derived aggregates before a deliberate full reconciliation. */
	public function reset_aggregates() {
		update_option(
			self::SUMMARY_OPTION,
			array(
				'schema'                 => self::MEMORY_SCHEMA,
				'indexed'                => 0,
				'scored'                 => 0,
				'excluded'               => 0,
				'exclusion_reasons'      => array(),
				'families'               => array(),
				'statuses'               => array(),
				'input_sources'          => array(),
				'exact_duplicate_groups' => 0,
				'exact_duplicate_pages'  => 0,
				'readiness_count'        => 0,
				'readiness_sums'         => array( 'seo' => 0, 'aeo' => 0, 'geo' => 0, 'aio' => 0 ),
				'readiness_bands'        => array( 'strong' => 0, 'improve' => 0, 'weak' => 0 ),
				'readiness_issues'       => array(),
				'last_synced_at'         => '',
			),
			false
		);
		update_option( self::HASH_INDEX_OPTION, array(), false );
	}

	/** Mark a synchronization checkpoint even when no content changed. */
	public function touch_sync() {
		$summary                   = $this->get_summary();
		$summary['last_synced_at'] = gmdate( 'c' );
		update_option( self::SUMMARY_OPTION, $summary, false );
	}

	/**
	 * Remove a permanently deleted post from aggregate memory before WordPress
	 * removes its postmeta.
	 *
	 * @param int $post_id Post ID.
	 */
	public function forget_post( $post_id ) {
		if ( $this->vector_store ) {
			$this->vector_store->delete( (int) $post_id );
		}
		$old = get_post_meta( (int) $post_id, self::META_KEY, true );
		if ( ! is_array( $old ) || empty( $old ) || empty( $old['active'] ) ) {
			return;
		}

		$summary    = $this->get_summary();
		$hash_index = get_option( self::HASH_INDEX_OPTION, array() );
		$recent     = get_option( self::RECENT_OPTION, array() );
		$hash_index = is_array( $hash_index ) ? $hash_index : array();
		$recent     = is_array( $recent ) ? $recent : array();
		$removed    = $old;
		$removed['active']      = false;
		$removed['status']      = 'deleted';
		$removed['observed_at'] = gmdate( 'c' );

		$this->update_summary_counts( $summary, $old, $removed );
		$this->update_hash_index( $hash_index, (int) $post_id, $old, $removed );
		$this->update_duplicate_summary( $summary, $hash_index );
		$summary['last_synced_at'] = gmdate( 'c' );

		array_unshift(
			$recent,
			array(
				'post_id'     => (int) $post_id,
				'title'       => isset( $old['title'] ) ? $old['title'] : __( 'Deleted content', 'content-miner' ),
				'edit_url'    => '',
				'family'      => isset( $old['family'] ) ? $old['family'] : 'other-page',
				'change_type' => 'removed',
				'observed_at' => gmdate( 'c' ),
			)
		);

		update_post_meta( (int) $post_id, self::META_KEY, $removed );
		update_option( self::SUMMARY_OPTION, $summary, false );
		update_option( self::HASH_INDEX_OPTION, $hash_index, false );
		update_option( self::RECENT_OPTION, array_slice( $recent, 0, self::MAX_RECENT_CHANGES ), false );
		// The saved analysis is derived from the previous inventory population.
		delete_option( 'content_miner_latest_scan' );
		delete_option( 'content_miner_analysis_state' );
	}

	/**
	 * @param array<string,mixed> $item Item.
	 * @return array<string,mixed>
	 */
	private function build_memory( array $item, array $analysis_content ) {
		$plain        = $this->normalize_text( $analysis_content['body'] );
		$content_hash = hash( 'sha256', $plain );
		$analysis_content_hash = $analysis_content['hash'];
		$active       = in_array( $item['status'], array( 'publish', 'draft', 'future', 'private' ), true );
		$scoring      = isset( $item['scoring'] ) && is_array( $item['scoring'] )
			? wp_parse_args( $item['scoring'], array( 'excluded' => false, 'scored' => true, 'automatic_reasons' => array(), 'primary_reason' => '', 'override' => '' ) )
			: array( 'excluded' => false, 'scored' => true, 'automatic_reasons' => array(), 'primary_reason' => '', 'override' => '' );
		$scoring['automatic_reasons'] = isset( $scoring['automatic_reasons'] ) && is_array( $scoring['automatic_reasons'] ) ? array_values( array_unique( array_map( 'sanitize_key', $scoring['automatic_reasons'] ) ) ) : array();
		$scoring['excluded'] = isset( $scoring['excluded'] ) && true === $scoring['excluded'];
		$scoring['scored']   = ! $scoring['excluded'];
		$readiness    = $scoring['excluded'] ? array() : $this->readiness->compact( $this->readiness->analyze_item( $item ) );
		$signature    = $this->analyzer
			? $this->analyzer->build_lexical_signature( $analysis_content['title'], $item['content'], 150 )
			: array();
		$fingerprint  = hash(
			'sha256',
			implode(
				'|',
				array(
					self::MEMORY_SCHEMA,
					$analysis_content['title'],
					$item['status'],
					$item['modified_at'],
					$content_hash,
					$analysis_content_hash,
					$analysis_content['source'],
					wp_json_encode( isset( $item['signals'] ) ? $item['signals'] : array() ),
					wp_json_encode( isset( $item['content_sources'] ) ? $item['content_sources'] : array() ),
					wp_json_encode( isset( $item['extraction_warnings'] ) ? $item['extraction_warnings'] : array() ),
					wp_json_encode( $scoring ),
				)
			)
		);

		return array(
			'schema'       => self::MEMORY_SCHEMA,
			'fingerprint'  => $fingerprint,
			'content_hash' => $content_hash,
			'analysis_content_hash' => $analysis_content_hash,
			'analysis_content_source' => $analysis_content['source'],
			'lexical_signature' => $signature,
			'family'       => $this->classifier->classify( $item ),
			'title'        => $item['title'],
			'analysis_title'=> $analysis_content['title'],
			'post_type'    => $item['post_type'],
			'status'       => $item['status'],
			'active'       => $active,
			'word_count'   => $this->count_words( $plain ),
			'signals'      => isset( $item['signals'] ) && is_array( $item['signals'] ) ? $item['signals'] : array(),
			'content_sources' => isset( $item['content_sources'] ) ? $item['content_sources'] : array(),
			'extraction_warnings' => isset( $item['extraction_warnings'] ) ? $item['extraction_warnings'] : array(),
			'scoring'      => $scoring,
			'readiness'    => $readiness,
			'modified_at'  => $item['modified_at'],
			'observed_at'  => gmdate( 'c' ),
		);
	}

	/**
	 * @param array<string,mixed> $summary Summary by reference.
	 * @param array<string,mixed> $old Old memory.
	 * @param array<string,mixed> $new New memory.
	 */
	private function update_summary_counts( array &$summary, array $old, array $new ) {
		$old_active = ! empty( $old['active'] );
		$new_active = ! empty( $new['active'] );

		if ( $old_active ) {
			$summary['indexed'] = max( 0, (int) $summary['indexed'] - 1 );
			$this->adjust_count( $summary['statuses'], $old['status'], -1 );
			$this->adjust_count( $summary['input_sources'], isset( $old['analysis_content_source'] ) ? $old['analysis_content_source'] : 'stored_fallback', -1 );
			$this->adjust_scoring( $summary, $old, -1 );
		}

		if ( $new_active ) {
			$summary['indexed'] = (int) $summary['indexed'] + 1;
			$this->adjust_count( $summary['statuses'], $new['status'], 1 );
			$this->adjust_count( $summary['input_sources'], isset( $new['analysis_content_source'] ) ? $new['analysis_content_source'] : 'stored_fallback', 1 );
			$this->adjust_scoring( $summary, $new, 1 );
		}
	}

	/** Adjust the mutually exclusive scored/excluded totals and scored-only readiness. */
	private function adjust_scoring( array &$summary, array $memory, $delta ) {
		if ( $this->is_in_scope( $memory ) ) {
			$summary['scored'] = max( 0, (int) $summary['scored'] + $delta );
			$this->adjust_count( $summary['families'], isset( $memory['family'] ) ? $memory['family'] : 'other-page', $delta );
			$this->adjust_readiness( $summary, isset( $memory['readiness'] ) ? $memory['readiness'] : array(), $delta );
			return;
		}

		$summary['excluded'] = max( 0, (int) $summary['excluded'] + $delta );
		$reasons = isset( $memory['scoring']['automatic_reasons'] ) && is_array( $memory['scoring']['automatic_reasons'] )
			? $memory['scoring']['automatic_reasons']
			: array();
		foreach ( $reasons as $reason ) {
			$this->adjust_count( $summary['exclusion_reasons'], sanitize_key( $reason ), $delta );
		}
	}

	/**
	 * @param array<string,mixed> $summary Summary by reference.
	 * @param array<string,mixed> $readiness Compact readiness.
	 * @param int                 $delta Direction.
	 */
	private function adjust_readiness( array &$summary, array $readiness, $delta ) {
		if ( empty( $readiness['scores'] ) || ! is_array( $readiness['scores'] ) ) {
			return;
		}
		$summary['readiness_count'] = max( 0, (int) $summary['readiness_count'] + $delta );
		foreach ( array( 'seo', 'aeo', 'geo', 'aio' ) as $lens ) {
			$score = isset( $readiness['scores'][ $lens ] ) ? (int) $readiness['scores'][ $lens ] : 0;
			$summary['readiness_sums'][ $lens ] = max( 0, (int) $summary['readiness_sums'][ $lens ] + ( $delta * $score ) );
		}
		if ( ! empty( $readiness['status'] ) ) {
			$this->adjust_count( $summary['readiness_bands'], $readiness['status'], $delta );
		}
		foreach ( isset( $readiness['issues'] ) && is_array( $readiness['issues'] ) ? $readiness['issues'] : array() as $issue ) {
			$this->adjust_count( $summary['readiness_issues'], $issue, $delta );
		}
	}

	/**
	 * @param array<string,int> $counts Counts by reference.
	 * @param string            $key Key.
	 * @param int               $delta Delta.
	 */
	private function adjust_count( array &$counts, $key, $delta ) {
		$counts[ $key ] = max( 0, (int) ( isset( $counts[ $key ] ) ? $counts[ $key ] : 0 ) + $delta );
		if ( 0 === $counts[ $key ] ) {
			unset( $counts[ $key ] );
		}
	}

	/**
	 * @param array<string,array<int,int>> $index Hash index by reference.
	 * @param int                          $post_id Post ID.
	 * @param array<string,mixed>          $old Old memory.
	 * @param array<string,mixed>          $new New memory.
	 */
	private function update_hash_index( array &$index, $post_id, array $old, array $new ) {
		if ( ! empty( $old['content_hash'] ) && isset( $index[ $old['content_hash'] ] ) ) {
			$index[ $old['content_hash'] ] = array_values( array_diff( $index[ $old['content_hash'] ], array( $post_id ) ) );
			if ( empty( $index[ $old['content_hash'] ] ) ) {
				unset( $index[ $old['content_hash'] ] );
			}
		}

		if ( ! empty( $new['active'] ) && $this->is_in_scope( $new ) && ! empty( $new['content_hash'] ) && $new['word_count'] >= 20 ) {
			if ( ! isset( $index[ $new['content_hash'] ] ) || ! is_array( $index[ $new['content_hash'] ] ) ) {
				$index[ $new['content_hash'] ] = array();
			}
			if ( ! in_array( $post_id, $index[ $new['content_hash'] ], true ) ) {
				$index[ $new['content_hash'] ][] = $post_id;
			}
		}
	}

	/**
	 * @param array<string,mixed>          $summary Summary by reference.
	 * @param array<string,array<int,int>> $index Hash index.
	 */
	private function update_duplicate_summary( array &$summary, array $index ) {
		$groups = 0;
		$pages  = 0;
		foreach ( $index as $post_ids ) {
			$count = is_array( $post_ids ) ? count( $post_ids ) : 0;
			if ( $count > 1 ) {
				++$groups;
				$pages += $count;
			}
		}

		$summary['exact_duplicate_groups'] = $groups;
		$summary['exact_duplicate_pages']  = $pages;
	}

	/**
	 * @param string $text Text.
	 * @return string
	 */
	private function normalize_text( $text ) {
		$text = function_exists( 'wp_strip_all_tags' ) ? wp_strip_all_tags( (string) $text ) : preg_replace( '/<[^>]*>/', '', (string) $text );
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text );
		$text = preg_replace( '/[^\p{L}\p{N}\s-]+/u', ' ', $text );
		$text = preg_replace( '/\s+/u', ' ', $text );

		return trim( (string) $text );
	}


	/** Legacy or malformed memory fails toward inclusion. */
	private function is_in_scope( array $memory ) {
		return Content_Miner_Scoring_Exclusions::is_in_scope( $memory );
	}

	/**
	 * @param string $text Normalized text.
	 * @return int
	 */
	private function count_words( $text ) {
		if ( '' === $text ) {
			return 0;
		}
		$words = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );

		return is_array( $words ) ? count( $words ) : 0;
	}
}
