<?php

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

/**
 * Resumable large-site ingestion and incremental synchronization.
 */
class Content_Miner_Ingestion {
	const STATE_OPTION = 'content_miner_ingestion_state';
	const CRON_HOOK    = 'content_miner_process_memory_batch';
	const LOCK_KEY     = 'content_miner_ingestion_lock';
	const VECTOR_BACKFILL_OPTION = 'content_miner_vector_backfill_required';
	const BATCH_SIZE   = 100;

	/** @var Content_Miner_Repository */
	private $repository;

	/** @var Content_Miner_Memory */
	private $memory;

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

	/** @var Content_Miner_Rendered_Verification|null */
	private $rendered_snapshots;

	/**
	 * @param Content_Miner_Repository $repository Repository.
	 * @param Content_Miner_Memory     $memory Memory store.
	 */
	public function __construct( $repository, $memory, $embedding_indexer = null, $rendered_snapshots = null ) {
		$this->repository = $repository;
		$this->memory     = $memory;
		$this->embedding_indexer = $embedding_indexer;
		$this->rendered_snapshots = $rendered_snapshots;

		add_action( self::CRON_HOOK, array( $this, 'process_batch' ) );
		add_action( 'save_post', array( $this, 'handle_post_saved' ), 20, 3 );
		add_action( 'before_delete_post', array( $this, 'handle_post_deleted' ), 20, 1 );
	}

	/**
	 * Start a new full or incremental walk. Existing page memory is preserved.
	 *
	 * @param string $mode full or incremental.
	 * @return array<string,mixed>
	 */
	public function start( $mode = 'incremental' ) {
		$mode = 'full' === $mode ? 'full' : 'incremental';
		if ( get_option( self::VECTOR_BACKFILL_OPTION, false ) ) {
			// A prior embedding failure must revisit existing IDs, while the
			// indexer's hash check still limits paid calls to stale pages.
			$mode = 'full';
		}
		$scoring_context = method_exists( $this->repository, 'refresh_scoring_context' )
			? $this->repository->refresh_scoring_context()
			: array( 'changed' => false, 'snapshot' => array() );
		if ( ! empty( $scoring_context['changed'] ) ) {
			// Sitemap membership is site-wide; a semantic change must reconcile old IDs too.
			$mode = 'full';
		}
		$sitemap_snapshot = isset( $scoring_context['snapshot'] ) && is_array( $scoring_context['snapshot'] ) ? $scoring_context['snapshot'] : array();
		$previous = $this->get_state();
		$cursor   = 0;

		if ( 'full' === $mode ) {
			// Every eligible record will be walked, so an old dirty queue is redundant.
			$this->memory->pull_dirty_posts( Content_Miner_Memory::MAX_DIRTY_QUEUE );
			$this->memory->reset_aggregates();
		} elseif ( 'complete' === $previous['status'] ) {
			// Saved posts are handled by the dirty queue. The sequential walk only
			// needs IDs created after the last complete checkpoint.
			$cursor = (int) $previous['max_id'];
		}

		$max_id          = $this->repository->get_max_id();
		$total           = 'full' === $mode ? $this->repository->get_total_count() : $this->repository->get_count_after_id( $cursor );
		$dirty_count     = $this->memory->get_dirty_count();
		if ( 'full' !== $mode && $dirty_count > 0 ) {
			$dirty_ids = method_exists( $this->memory, 'get_dirty_post_ids' ) ? $this->memory->get_dirty_post_ids() : array();
			$total += $dirty_ids ? count( array_filter( $dirty_ids, static function ( $post_id ) use ( $cursor ) { return (int) $post_id <= (int) $cursor; } ) ) : $dirty_count;
		}
		$dirty_only      = 'full' !== $mode && 0 === $total && $dirty_count > 0;
		if ( $dirty_only ) {
			$total = $dirty_count;
		}
		$total_available = method_exists( $this->repository, 'get_uncapped_total_count' ) ? $this->repository->get_uncapped_total_count() : $this->repository->get_total_count();
		$page_limit      = method_exists( $this->repository, 'get_page_limit' ) ? $this->repository->get_page_limit() : PHP_INT_MAX;
		$limit_reached   = method_exists( $this->repository, 'is_limit_reached' ) ? $this->repository->is_limit_reached() : false;
		$snapshot_state  = $this->rendered_snapshots
			// A full memory walk immediately consumes every snapshot, so queuing
			// those same IDs again creates a false dirty sync after completion.
			? $this->rendered_snapshots->start( false, false, 'full' !== $mode )
			: array( 'status' => 'complete', 'total' => 0, 'fetched' => 0, 'fallback' => 0, 'remaining' => 0 );
		$state = array(
			'job_id'       => wp_generate_uuid4(),
			'mode'         => $mode,
			'status'       => 'queued',
			'cursor'       => $cursor,
			'max_id'       => $max_id,
			'total'        => $total,
			'total_available' => $total_available,
			'page_limit'   => $page_limit,
			'limit_reached' => $limit_reached,
			'processed'    => 0,
			'dirty_only'   => $dirty_only,
			'changed'      => 0,
			'skipped'      => 0,
			'stage'        => 'complete' === $snapshot_state['status'] ? 'memory' : 'rendering',
			'snapshot_total' => isset( $snapshot_state['total'] ) ? (int) $snapshot_state['total'] : 0,
			'snapshot_fetched' => isset( $snapshot_state['fetched'] ) ? (int) $snapshot_state['fetched'] : 0,
			'snapshot_fallback' => isset( $snapshot_state['fallback'] ) ? (int) $snapshot_state['fallback'] : 0,
			'snapshot_remaining' => isset( $snapshot_state['remaining'] ) ? (int) $snapshot_state['remaining'] : 0,
			'snapshot_request_duration_ms' => 0,
			'snapshot_parse_duration_ms' => 0,
			'snapshot_queue_duration_ms' => 0,
			'snapshot_fallback_counts' => array(),
			'batch_size'   => (int) apply_filters( 'content_miner_memory_batch_size', self::BATCH_SIZE ),
			'started_at'   => gmdate( 'c' ),
			'updated_at'   => gmdate( 'c' ),
			'completed_at' => '',
			'last_error'   => '',
			'sitemap_status' => isset( $sitemap_snapshot['status'] ) ? sanitize_key( $sitemap_snapshot['status'] ) : 'unavailable',
			'sitemap_reason' => isset( $sitemap_snapshot['reason'] ) ? sanitize_key( $sitemap_snapshot['reason'] ) : '',
			'sitemap_membership_known' => ! empty( $sitemap_snapshot['membership_known'] ),
		);

		update_option( self::STATE_OPTION, $state, false );
		$this->schedule_next_batch( 1 );

		// Complete one bounded batch immediately so the user sees progress.
		$this->process_batch( 'foreground' );

		return $this->get_state();
	}

	/**
	 * Process one safe batch and schedule the next checkpoint.
	 *
	 * @return array<string,mixed>
	 */
	public function process_batch( $runner = 'background' ) {
		$state = $this->get_state();
		if ( ! in_array( $state['status'], array( 'queued', 'running' ), true ) ) {
			if ( $this->memory->get_dirty_count() > 0 ) {
				$this->process_dirty_queue( $this->rendered_snapshots ? 5 : 20 );
				if ( $this->memory->get_dirty_count() > 0 ) {
					$this->schedule_next_batch( 10 );
				}
			}
			return $state;
		}

		if ( ! $this->acquire_lock( $state['job_id'] ) ) {
			return $state;
		}

		$batch_started = microtime( true );
		$work_before   = $this->count_processed_work( $state );
		$state['status']     = 'running';
		$state['updated_at'] = gmdate( 'c' );
		$state['last_runner'] = $this->normalize_runner( $runner );
		if ( 'full' !== $state['mode'] && 0 === (int) $state['total'] && $this->memory->get_dirty_count() > 0 ) {
			// Recover the zero-record incremental state written by the v2.0
			// assembly bug without discarding its already queued page IDs.
			$state['dirty_only'] = true;
			$state['total'] = (int) $state['processed'] + $this->memory->get_dirty_count();
		}
		update_option( self::STATE_OPTION, $state, false );

		try {
			if ( $this->rendered_snapshots && 'rendering' === $state['stage'] ) {
				$snapshot_state = $this->rendered_snapshots->process_batch();
				$this->copy_snapshot_progress( $state, $snapshot_state );
				if ( 'failed' === $snapshot_state['status'] ) {
					$state['status'] = 'failed';
					$state['last_error'] = isset( $snapshot_state['last_error'] ) ? sanitize_text_field( $snapshot_state['last_error'] ) : __( 'Rendered input collection failed.', 'content-miner' );
					$state['updated_at'] = gmdate( 'c' );
					$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
					update_option( self::STATE_OPTION, $state, false );
					return $state;
				}
				if ( 'complete' !== $snapshot_state['status'] ) {
					$state['updated_at'] = gmdate( 'c' );
					$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
					update_option( self::STATE_OPTION, $state, false );
					$next_delay = 3;
					if ( 'retry' === ( isset( $snapshot_state['phase'] ) ? $snapshot_state['phase'] : '' )
						&& (int) ( isset( $snapshot_state['retry_after'] ) ? $snapshot_state['retry_after'] : 0 ) > time()
					) {
						$next_delay = max( 3, (int) $snapshot_state['retry_after'] - time() );
					}
					$this->schedule_next_batch( $next_delay );
					return $state;
				}
				$state['stage'] = 'memory';
				$state['updated_at'] = gmdate( 'c' );
				if ( 0 === (int) $state['total'] && 0 === $this->memory->get_dirty_count() ) {
					$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
					return $this->complete( $state );
				}
				$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
				update_option( self::STATE_OPTION, $state, false );
				$this->schedule_next_batch( 1 );
				return $state;
			}

			$dirty_result = array( 'changed' => 0, 'skipped' => 0, 'processed' => 0 );
			if ( 'full' !== $state['mode'] ) {
				$dirty_result = $this->process_dirty_queue( $this->rendered_snapshots ? 5 : 20, (int) $state['cursor'] );
			}
			$state['changed']  += (int) $dirty_result['changed'];
			$state['skipped']  += (int) $dirty_result['skipped'];
			$state['processed'] += (int) $dirty_result['processed'];
			$state['total'] = max( (int) $state['total'], (int) $state['processed'] );
			$batch = $this->repository->get_inventory_batch( $state['cursor'], $state['batch_size'] );

			if ( empty( $batch ) ) {
				if ( ! empty( $state['dirty_only'] ) && $this->memory->get_dirty_count() > 0 ) {
					$state['updated_at'] = gmdate( 'c' );
					$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
					update_option( self::STATE_OPTION, $state, false );
					$this->schedule_next_batch( 10 );
					return $state;
				}
				$state = $this->complete( $state );
				return $state;
			}

			$result             = $this->memory->remember_items( $batch, 'full' === $state['mode'] );
			$this->index_embeddings( $result, $state );
			$last               = end( $batch );
			$state['cursor']     = (int) $last['id'];
			$state['processed'] += count( $batch );
			$state['changed']   += (int) $result['changed'];
			$state['skipped']   += (int) $result['skipped'];
			$state['total'] = max( (int) $state['total'], (int) $state['processed'] );
			$state['updated_at'] = gmdate( 'c' );

			if ( $this->memory->get_dirty_count() > 0 ) {
				$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
				update_option( self::STATE_OPTION, $state, false );
				$this->schedule_next_batch( 3 );
			} elseif ( count( $batch ) < $state['batch_size'] || $state['cursor'] >= $state['max_id'] ) {
				$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
				$state = $this->complete( $state );
			} else {
				$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
				update_option( self::STATE_OPTION, $state, false );
				$this->schedule_next_batch( 3 );
			}
		} catch ( Throwable $error ) {
			$state['status']     = 'failed';
			$state['last_error'] = sanitize_text_field( $error->getMessage() );
			$state['updated_at'] = gmdate( 'c' );
			$this->record_batch_metrics( $state, $runner, $batch_started, $work_before );
			update_option( self::STATE_OPTION, $state, false );
		} finally {
			delete_option( self::LOCK_KEY );
		}

		return $state;
	}

	/**
	 * Acquire the ingestion lock with one stale-lock recovery attempt.
	 *
	 * @param string $job_id Current job identifier.
	 * @return bool
	 */
	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 )
			&& isset( $existing['job_id'], $existing['locked_at'] )
			&& (string) $existing['job_id'] === (string) $job_id
			&& time() - (int) $existing['locked_at'] <= 2 * MINUTE_IN_SECONDS
		) {
			return false;
		}

		delete_option( self::LOCK_KEY );
		return add_option( self::LOCK_KEY, $payload, '', false );
	}

	/**
	 * Queue eligible saved posts without doing heavy analysis in the save request.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post Post.
	 * @param bool    $update Whether existing.
	 */
	public function handle_post_saved( $post_id, $post, $update ) {
		unset( $update );
		if ( defined( 'CONTENT_MINER_LOADING_FIXTURES' ) && CONTENT_MINER_LOADING_FIXTURES ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}

		if ( ! in_array( $post->post_type, $this->repository->get_post_types(), true ) ) {
			return;
		}

		$this->memory->queue_dirty_post( $post_id );
		$this->schedule_next_batch( 10 );
	}

	/** Remove permanently deleted content from remembered aggregates. */
	public function handle_post_deleted( $post_id ) {
		$this->memory->forget_post( $post_id );
	}

	/**
	 * @return array<string,mixed>
	 */
	public function get_state() {
		$state = get_option( self::STATE_OPTION, array() );
		$state = is_array( $state ) ? $state : array();

		return wp_parse_args(
			$state,
			array(
				'job_id'       => '',
				'mode'         => '',
				'status'       => 'idle',
				'cursor'       => 0,
				'max_id'       => 0,
				'total'        => 0,
				'total_available' => 0,
				'page_limit'   => PHP_INT_MAX,
				'limit_reached' => false,
				'processed'    => 0,
				'dirty_only'   => false,
				'changed'      => 0,
				'skipped'      => 0,
				'stage'        => 'memory',
				'snapshot_total' => 0,
				'snapshot_fetched' => 0,
				'snapshot_fallback' => 0,
				'snapshot_remaining' => 0,
				'snapshot_request_duration_ms' => 0,
				'snapshot_parse_duration_ms' => 0,
				'snapshot_queue_duration_ms' => 0,
				'snapshot_fallback_counts' => array(),
				'batch_size'   => self::BATCH_SIZE,
				'started_at'   => '',
				'updated_at'   => '',
				'completed_at' => '',
				'last_error'   => '',
				'sitemap_status' => 'unavailable',
				'sitemap_reason' => '',
				'sitemap_membership_known' => false,
				'last_runner'  => '',
				'last_batch_at' => '',
				'last_batch_records' => 0,
				'last_batch_duration_ms' => 0,
				'last_batch_pages_per_minute' => 0,
			)
		);
	}

	/**
	 * @param int $limit Dirty items.
	 */
	private function process_dirty_queue( $limit, $covered_after_id = PHP_INT_MAX ) {
		$items = array();
		foreach ( $this->memory->pull_dirty_posts( $limit ) as $post_id ) {
			if ( (int) $post_id > (int) $covered_after_id ) {
				continue;
			}
			if ( $this->rendered_snapshots ) {
				$refresh = $this->rendered_snapshots->refresh_post( $post_id );
				if ( empty( $refresh['ready'] ) ) {
					$this->memory->queue_dirty_post( $post_id );
					continue;
				}
			}
			$item = $this->repository->get_item( $post_id );
			if ( $item ) {
				$items[] = $item;
			}
		}

		if ( $items ) {
			$result = $this->memory->remember_items( $items );
			$state = $this->get_state();
			$this->index_embeddings( $result, $state );
			$result['processed'] = (int) $result['changed'] + (int) $result['skipped'];
			return $result;
		}

		return array(
			'changed' => 0,
			'skipped' => 0,
			'processed' => 0,
		);
	}

	private function copy_snapshot_progress( array &$state, array $snapshot ) {
		$state['snapshot_total'] = isset( $snapshot['total'] ) ? (int) $snapshot['total'] : 0;
		$state['snapshot_fetched'] = isset( $snapshot['fetched'] ) ? (int) $snapshot['fetched'] : 0;
		$state['snapshot_fallback'] = isset( $snapshot['fallback'] ) ? (int) $snapshot['fallback'] : 0;
		$state['snapshot_remaining'] = isset( $snapshot['remaining'] ) ? (int) $snapshot['remaining'] : 0;
		$state['snapshot_request_duration_ms'] = isset( $snapshot['request_duration_ms'] ) ? (int) $snapshot['request_duration_ms'] : 0;
		$state['snapshot_parse_duration_ms'] = isset( $snapshot['parse_duration_ms'] ) ? (int) $snapshot['parse_duration_ms'] : 0;
		$state['snapshot_queue_duration_ms'] = isset( $snapshot['queue_duration_ms'] ) ? (int) $snapshot['queue_duration_ms'] : 0;
		$state['snapshot_fallback_counts'] = isset( $snapshot['fallback_counts'] ) && is_array( $snapshot['fallback_counts'] ) ? $snapshot['fallback_counts'] : array();
	}

	private function count_processed_work( array $state ) {
		if ( 'rendering' === ( isset( $state['stage'] ) ? $state['stage'] : '' ) ) {
			return (int) ( isset( $state['snapshot_fetched'] ) ? $state['snapshot_fetched'] : 0 )
				+ (int) ( isset( $state['snapshot_fallback'] ) ? $state['snapshot_fallback'] : 0 );
		}
		return (int) ( isset( $state['processed'] ) ? $state['processed'] : 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, $this->count_processed_work( $state ) - (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';
	}

	/** Embedding failures are advisory and never fail lexical ingestion. */
	private function index_embeddings( array $result, array &$state ) {
		if ( ! $this->embedding_indexer || empty( $result['vector_items'] ) ) {
			return;
		}
		$indexed = $this->embedding_indexer->index( $result['vector_items'] );
		if ( is_wp_error( $indexed ) ) {
			update_option( self::VECTOR_BACKFILL_OPTION, true, false );
			$state['last_error'] = sanitize_text_field( $indexed->get_error_message() );
			$state['updated_at'] = gmdate( 'c' );
			update_option( self::STATE_OPTION, $state, false );
		}
	}

	/**
	 * @param array<string,mixed> $state State.
	 * @return array<string,mixed>
	 */
	private function complete( array $state ) {
		$state['processed'] = (int) $state['changed'] + (int) $state['skipped'];
		$state['total'] = max( (int) $state['total'], (int) $state['processed'] );
		$state['status']       = 'complete';
		$state['stage']        = 'complete';
		$state['updated_at']   = gmdate( 'c' );
		$state['completed_at'] = gmdate( 'c' );
		update_option( self::STATE_OPTION, $state, false );
		$this->memory->touch_sync();
		$indexer_available = $this->embedding_indexer
			&& ( ! method_exists( $this->embedding_indexer, 'is_configured' ) || $this->embedding_indexer->is_configured() );
		if ( empty( $state['last_error'] )
			&& ( ! get_option( self::VECTOR_BACKFILL_OPTION, false ) || $indexer_available )
		) {
			// Do not silently cancel paid work while Pro or its key vault is
			// temporarily unavailable. Explicit key removal clears this flag.
			delete_option( self::VECTOR_BACKFILL_OPTION );
		}
		if ( $this->memory->get_dirty_count() > 0 ) {
			$this->schedule_next_batch( 1 );
		}
		if ( function_exists( 'do_action' ) ) {
			do_action( 'content_miner_ingestion_completed', $state );
		}

		return $state;
	}

	/**
	 * @param int $delay Delay seconds.
	 */
	private function schedule_next_batch( $delay ) {
		if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_single_event( time() + max( 1, (int) $delay ), self::CRON_HOOK );
		}
	}
}
