<?php

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

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

/** Resumable full-library recommendation and collision analysis. */
class Content_Miner_Analysis_Job {
	const STATE_OPTION = 'content_miner_analysis_state';
	const RESULT_OPTION = 'content_miner_latest_scan';
	const CRON_HOOK = 'content_miner_process_analysis_batch';
	const LOCK_KEY = 'content_miner_analysis_lock';
	const BATCH_SIZE = 100;
	const CANDIDATE_CHUNK = 500;
	const SIGNATURE_THRESHOLD = 0.30;
	const SEMANTIC_THRESHOLD = 0.80;

	private $repository;
	private $analyzer;
	private $memory;
	private $vectors;
	private $openai;
	private $free_scan_limit;

	public function __construct( $repository, $analyzer, $memory, $vectors, $openai = null, $free_scan_limit = null ) {
		$this->repository = $repository;
		$this->analyzer   = $analyzer;
		$this->memory     = $memory;
		$this->vectors    = $vectors;
		$this->openai     = $openai;
		$this->free_scan_limit = $free_scan_limit;
		add_action( self::CRON_HOOK, array( $this, 'process_batch' ) );
	}

	/** @return array<string,mixed> */
	public function start( array $locations = array() ) {
		$state = array(
			'job_id'       => wp_generate_uuid4(),
			'status'       => 'queued',
			'cursor'       => 0,
			'max_id'       => $this->repository->get_max_id(),
			'total'        => $this->repository->get_total_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,
			'processed'    => 0,
			'batch_size'   => (int) apply_filters( 'content_miner_analysis_batch_size', self::BATCH_SIZE ),
			'locations'    => array_values( array_map( 'strval', $locations ) ),
			'started_at'   => gmdate( 'c' ),
			'updated_at'   => gmdate( 'c' ),
			'completed_at' => '',
			'last_error'   => '',
			'summary'      => array( 'keep' => 0, 'refresh' => 0, 'localize' => 0, 'review' => 0, 'scored' => 0, 'excluded' => 0, 'exclusion_reasons' => array(), 'overlap_pairs' => 0, 'overlap_pairs_total' => 0 ),
			'pairs'        => array(),
		);
		update_option( self::STATE_OPTION, $state, false );
		$this->schedule_next( 1 );
		return $this->process_batch( 'foreground' );
	}

	/** Process at most 100 target pages while scanning candidates in 500-row chunks. @return array<string,mixed> */
	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 );
		$processed_before = (int) $state['processed'];
		$state['status'] = 'running';
		$state['last_runner'] = $this->normalize_runner( $runner );
		update_option( self::STATE_OPTION, $state, false );
		try {
			$targets = $this->repository->get_inventory_batch( $state['cursor'], $state['batch_size'] );
			if ( empty( $targets ) ) {
				return $this->complete( $state );
			}
			$prepared_targets = array();
			$target_signatures = array();
			$collisions = array();
			$batch_pairs = array();
			$thorough = (bool) apply_filters( 'content_miner_analysis_thorough', false );
			foreach ( $targets as $item ) {
				$prepared = $this->analyzer->prepare_for_analysis( $item, $state['locations'] );
				if ( ! self::item_is_in_scope( $item ) ) {
					$prepared['overlap_count'] = 0;
					$this->memory->update_analysis( (int) $item['id'], $prepared );
					++$state['summary']['excluded'];
					$reason = isset( $item['scoring']['primary_reason'] ) ? sanitize_key( $item['scoring']['primary_reason'] ) : '';
					if ( $reason ) {
						$state['summary']['exclusion_reasons'][ $reason ] = isset( $state['summary']['exclusion_reasons'][ $reason ] ) ? (int) $state['summary']['exclusion_reasons'][ $reason ] + 1 : 1;
					}
					continue;
				}
				$prepared_targets[ (int) $item['id'] ] = $prepared;
				$target_signatures[ (int) $item['id'] ] = $this->analyzer->build_lexical_signature( isset( $item['analysis_title'] ) ? $item['analysis_title'] : $item['title'], $item['content'], 150 );
				$collisions[ (int) $item['id'] ] = array();
			}

			$shortlist = array();
			$expected_vector_hashes = array();
			$candidate_cursor = 0;
			do {
				$signature_rows = $this->memory->get_signature_chunk( $candidate_cursor, self::CANDIDATE_CHUNK );
				foreach ( $signature_rows as $signature_row ) {
					$candidate_id = (int) $signature_row['post_id'];
					$candidate_cursor = max( $candidate_cursor, $candidate_id );
					if ( $candidate_id > (int) $state['max_id'] ) {
						continue;
					}
					if ( empty( $signature_row['active'] ) || ( array_key_exists( 'scored', $signature_row ) && empty( $signature_row['scored'] ) ) ) {
						continue;
					}
					if ( ! empty( $signature_row['analysis_content_hash'] ) ) {
						$expected_vector_hashes[ $candidate_id ] = (string) $signature_row['analysis_content_hash'];
					}
					foreach ( $target_signatures as $target_id => $target_signature ) {
						if ( $target_id === $candidate_id ) {
							continue;
						}
						if ( $thorough || ( ! empty( $signature_row['lexical_signature'] ) && $this->analyzer->signature_overlap( $target_signature, $signature_row['lexical_signature'] ) >= self::SIGNATURE_THRESHOLD ) ) {
							$shortlist[ $candidate_id ][ $target_id ] = true;
						}
					}
				}
			} while ( count( $signature_rows ) === self::CANDIDATE_CHUNK && $candidate_cursor < (int) $state['max_id'] );

			$prepared_candidates = array();
			foreach ( $shortlist as $candidate_id => $target_ids ) {
				$candidate = $this->get_prepared_candidate( $candidate_id, $prepared_candidates, $state['locations'] );
				if ( empty( $candidate ) ) {
					continue;
				}
				foreach ( array_keys( $target_ids ) as $target_id ) {
					$pair = $this->analyzer->compare_prepared( $prepared_targets[ $target_id ], $candidate );
					if ( null === $pair ) {
						continue;
					}
					$exact = ! empty( $pair['exact'] );
					$pair['evidence'] = $exact
						? __( 'exact copy', 'content-miner' )
						/* translators: %d: lexical overlap percentage. */
						: sprintf( __( 'lexical overlap %d%%', 'content-miner' ), round( $pair['score'] * 100 ) );
					$collisions[ $target_id ][] = $pair;
					if ( $target_id < (int) $candidate_id ) {
						$batch_pairs[] = $pair;
					}
				}
			}

			$semantic_by_target = $this->semantic_pairs_batch( $prepared_targets, $prepared_candidates, $state['locations'], $state['max_id'], $expected_vector_hashes );

			foreach ( $prepared_targets as $target_id => &$target ) {
				++$state['summary']['scored'];
				$semantic = isset( $semantic_by_target[ $target_id ] ) ? $semantic_by_target[ $target_id ] : array();
				$all = Content_Miner_Candidate_Union::merge( $collisions[ $target_id ], $semantic );
				$target['overlap_count'] = count( $all );
				$this->memory->update_analysis( $target_id, $target );
				$action = strtolower( $target['recommendation']['action'] );
				if ( isset( $state['summary'][ $action ] ) ) {
					++$state['summary'][ $action ];
				}
				foreach ( $semantic as $pair ) {
					if ( $target_id < (int) $pair['right_id'] ) {
						$batch_pairs[] = $pair;
					}
				}
			}
			unset( $target );

			$batch_pairs = Content_Miner_Candidate_Union::merge( array(), $batch_pairs );
			if ( ! isset( $state['summary']['overlap_pairs_total'] ) ) {
				$state['summary']['overlap_pairs_total'] = count( $state['pairs'] );
			}
			$state['summary']['overlap_pairs_total'] += count( $batch_pairs );
			$state['pairs'] = array_slice( Content_Miner_Candidate_Union::merge( $state['pairs'], $batch_pairs ), 0, 100 );
			$state['summary']['overlap_pairs'] = count( $state['pairs'] );
			$last = end( $targets );
			$state['cursor'] = (int) $last['id'];
			$state['processed'] += count( $targets );
			$state['updated_at'] = gmdate( 'c' );
			$this->record_batch_metrics( $state, $runner, $batch_started, $processed_before );
			$this->save_result( $state );
			if ( count( $targets ) < $state['batch_size'] || $state['cursor'] >= $state['max_id'] ) {
				$state = $this->complete( $state );
			} else {
				update_option( self::STATE_OPTION, $state, false );
				$this->schedule_next( 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, $processed_before );
			update_option( self::STATE_OPTION, $state, false );
		} finally {
			delete_option( self::LOCK_KEY );
		}
		return $state;
	}

	/** @return array<int,array<string,mixed>> */
	private function semantic_pairs_batch( array $targets, array &$prepared_candidates, array $locations, $max_post_id = PHP_INT_MAX, array $expected_vector_hashes = array() ) {
		$by_target = array_fill_keys( array_keys( $targets ), array() );
		if ( ! $this->openai || empty( $this->openai->get_status()['configured'] ) ) {
			return $by_target;
		}
		$needles = $this->vectors->get_vectors( array_keys( $targets ) );
		$vector_hashes = method_exists( $this->vectors, 'get_content_hashes' )
			? $this->vectors->get_content_hashes( array_keys( $targets ) )
			: null;
		if ( is_array( $vector_hashes ) ) {
			foreach ( $needles as $target_id => $needle ) {
				if ( ! isset( $vector_hashes[ $target_id ] ) || ! hash_equals( (string) $vector_hashes[ $target_id ], $this->embedding_content_hash( $targets[ $target_id ] ) ) ) {
					unset( $needles[ $target_id ] );
				}
			}
		}
		if ( empty( $needles ) ) {
			return $by_target;
		}
		$matches_by_target = $this->vectors->top_matches_batch( $needles, 3, $max_post_id, $expected_vector_hashes );
		foreach ( $matches_by_target as $target_id => $matches ) {
			foreach ( $matches as $match ) {
				if ( (float) $match['score'] < self::SEMANTIC_THRESHOLD ) {
					continue;
				}
				$candidate_id = (int) $match['post_id'];
				$right = $this->get_prepared_candidate( $candidate_id, $prepared_candidates, $locations );
				if ( empty( $right ) ) {
					continue;
				}
				if ( $expected_vector_hashes && ( ! isset( $expected_vector_hashes[ $candidate_id ] ) || ! hash_equals( (string) $expected_vector_hashes[ $candidate_id ], $this->embedding_content_hash( $right ) ) ) ) {
					continue;
				}
				$target = $targets[ $target_id ];
				$by_target[ $target_id ][] = Content_Miner_Collision_Recommendation::resolve( array(
					'left_id' => $target['id'], 'left_title' => $target['title'], 'left_url' => $target['edit_url'],
					'right_id' => $right['id'], 'right_title' => $right['title'], 'right_url' => $right['edit_url'],
					'score' => round( (float) $match['score'], 4 ),
						/* translators: %.2f: semantic similarity score from zero to one. */
						'evidence' => sprintf( __( 'semantic similarity %.2f', 'content-miner' ), $match['score'] ),
				) );
			}
		}
		return $by_target;
	}

	/** Hash the exact title-and-body payload used by the embedding indexer. */
	private function embedding_content_hash( array $item ) {
		$analysis_content = Content_Miner_Analysis_Content::from_item( $item );
		return $analysis_content['hash'];
	}

	/** Fetch and prepare a shortlisted item at most once per target batch. @return array<string,mixed> */
	private function get_prepared_candidate( $post_id, array &$cache, array $locations ) {
		$post_id = (int) $post_id;
		if ( isset( $cache[ $post_id ] ) ) {
			return $cache[ $post_id ];
		}
		$item = $this->repository->get_item( $post_id );
		if ( empty( $item ) || ! self::item_is_in_scope( $item ) ) {
			return array();
		}
		$cache[ $post_id ] = $this->analyzer->prepare_for_analysis( $item, $locations );
		return $cache[ $post_id ];
	}


	/** Legacy or malformed items fail toward inclusion. */
	private static function item_is_in_scope( array $item ) {
		return Content_Miner_Scoring_Exclusions::is_in_scope( $item );
	}

	/** @return array<string,mixed> */
	public function get_state() {
		$state = get_option( self::STATE_OPTION, array() );
		return wp_parse_args( is_array( $state ) ? $state : array(), array( 'job_id' => '', 'status' => 'idle', 'cursor' => 0, 'max_id' => 0, 'total' => 0, 'total_available' => 0, 'page_limit' => PHP_INT_MAX, 'limit_reached' => false, 'processed' => 0, 'batch_size' => self::BATCH_SIZE, 'locations' => array(), '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, 'summary' => array(), 'pairs' => array() ) );
	}

	private function complete( array $state ) {
		$state['status'] = 'complete';
		$state['updated_at'] = gmdate( 'c' );
		$state['completed_at'] = gmdate( 'c' );
		update_option( self::STATE_OPTION, $state, false );
		$this->save_result( $state );
		if ( $this->free_scan_limit && method_exists( $this->free_scan_limit, 'record_completed_analysis' ) ) {
			$this->free_scan_limit->record_completed_analysis( $state['job_id'] );
		}
		return $state;
	}

	private function save_result( array $state ) {
		update_option( self::RESULT_OPTION, array( 'generated_at' => $state['updated_at'], 'item_count' => (int) $state['processed'], 'items' => array(), 'pairs' => $state['pairs'], 'summary' => $state['summary'] ), false );
	}

	private function record_batch_metrics( array &$state, $runner, $started_at, $processed_before ) {
		$duration_ms = max( 1, (int) round( ( microtime( true ) - (float) $started_at ) * 1000 ) );
		$records = max( 0, (int) $state['processed'] - (int) $processed_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 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 );
		}
	}
}
