<?php

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

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

/**
 * WordPress admin UI and form handlers.
 */
class Content_Miner_Admin {
	const PAGE_SLUG   = 'content-miner';
	const SCAN_OPTION = 'content_miner_latest_scan';
	const LOCATIONS_OPTION = 'content_miner_locations';

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

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

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

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

	/** @var Content_Miner_Ingestion */
	private $ingestion;

	/** @var Content_Miner_Analysis_Job */
	private $analysis;

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

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

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

	private $ledger;

	private $digest;

	private $verdicts;

	private $verification;

	private $pro_enabled;

	private $google_enabled;

	private $semantic_enabled;

	private $outcomes_enabled;

	private $digest_enabled;

	private $verdicts_enabled;

	private $verification_enabled;

	private $ai_explain_enabled;

	private $prompt_builder;

	private $consultation;

	/** @var Content_Miner_Scoring_Exclusions|null */
	private $scoring_exclusions;

	/** @var Content_Miner_Free_Scan_Limit|null */
	private $free_scan_limit;

	/** @var Content_Miner_Analysis_Handoff|null */
	private $analysis_handoff;

	/** @var Content_Miner_Cloud_Connector|null */
	private $cloud_connector;

	/**
	 * @param Content_Miner_Repository $repository Repository.
	 * @param Content_Miner_Analyzer   $analyzer Analyzer.
	 * @param Content_Miner_Readiness_Analyzer $readiness Readiness analyzer.
	 * @param Content_Miner_Memory     $memory Memory.
	 * @param Content_Miner_Ingestion  $ingestion Ingestion coordinator.
	 * @param object|null $search_console Search Console connector.
	 * @param object|null $analytics GA4 connector.
	 */
	public function __construct( $repository, $analyzer, $readiness, $memory, $ingestion, $analysis, $openai, $search_console, $analytics, $ledger = null, $digest = null, $verdicts = null, $verification = null, $prompt_builder = null, $consultation = null, $scoring_exclusions = null, $free_scan_limit = null, $analysis_handoff = null, $cloud_connector = null ) {
		$this->repository = $repository;
		$this->analyzer   = $analyzer;
		$this->readiness  = $readiness;
		$this->memory     = $memory;
		$this->ingestion  = $ingestion;
		$this->analysis   = $analysis;
		$this->openai     = $openai;
		$this->search_console = $search_console;
		$this->analytics      = $analytics;
		$this->pro_enabled    = function_exists( 'content_miner_is_pro' ) ? content_miner_is_pro() : true;
		$this->google_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_GOOGLE_EVIDENCE ) : $this->pro_enabled;
		$this->semantic_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_SEMANTIC_ANALYSIS ) : $this->pro_enabled;
		$this->outcomes_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_OUTCOMES ) : $this->pro_enabled;
		$this->digest_enabled   = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_DIGEST ) : $this->pro_enabled;
		$this->verdicts_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_LLM_VERDICTS ) : $this->pro_enabled;
		$this->verification_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_RENDERED_VERIFICATION ) : $this->pro_enabled;
		$this->ai_explain_enabled = function_exists( 'content_miner_has_feature' ) ? content_miner_has_feature( Content_Miner_License::FEATURE_AI_EXPLAIN ) : $this->pro_enabled;
		$this->ledger         = $ledger ? $ledger : new Content_Miner_Decision_Ledger();
		$this->digest         = $digest;
		$this->verdicts       = $verdicts;
		$this->verification   = $verification;
		$this->prompt_builder = $prompt_builder ? $prompt_builder : new Content_Miner_Prompt_Builder();
		$this->consultation   = $consultation ? $consultation : new Content_Miner_Consultation_CTA();
		$this->scoring_exclusions = $scoring_exclusions ? $scoring_exclusions : ( method_exists( $repository, 'get_scoring_exclusions' ) ? $repository->get_scoring_exclusions() : null );
		$this->free_scan_limit = $free_scan_limit;
		$this->analysis_handoff = $analysis_handoff;
		$this->cloud_connector = $cloud_connector;

		add_action( 'admin_menu', array( $this, 'register_menu' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
		add_action( 'admin_post_content_miner_run_scan', array( $this, 'handle_scan' ) );
		add_action( 'admin_post_content_miner_process_analysis', array( $this, 'handle_process_analysis' ) );
		add_action( 'admin_post_content_miner_start_ingestion', array( $this, 'handle_start_ingestion' ) );
		add_action( 'admin_post_content_miner_process_batch', array( $this, 'handle_process_batch' ) );
		add_action( 'wp_ajax_content_miner_pipeline_tick', array( $this, 'handle_pipeline_tick' ) );
		if ( $this->free_scan_limit ) {
			add_action( 'admin_post_content_miner_activate_free_analyses', array( $this, 'handle_activate_free_analyses' ) );
		}
		add_action( 'admin_post_content_miner_check_idea', array( $this, 'handle_idea_check' ) );
		add_action( 'admin_post_content_miner_save_settings', array( $this, 'handle_settings' ) );
		add_action( 'admin_post_content_miner_record_decision', array( $this, 'handle_decision' ) );
		add_action( 'admin_post_content_miner_download_review_workflow', array( $this, 'handle_download_review_workflow' ) );
		if ( $this->scoring_exclusions ) {
			add_action( 'admin_post_content_miner_set_scoring_override', array( $this, 'handle_scoring_override' ) );
		}
		add_action( 'admin_post_content_miner_save_consultation', array( $this, 'handle_consultation_settings' ) );
		if ( $this->cloud_connector ) {
			add_action( 'admin_post_content_miner_cloud_connect', array( $this, 'handle_cloud_connect' ) );
			add_action( 'admin_post_content_miner_cloud_upload', array( $this, 'handle_cloud_upload' ) );
		}
		if ( $this->ai_explain_enabled && $this->openai ) {
			add_action( 'admin_post_content_miner_explain_prompt', array( $this, 'handle_explain_prompt' ) );
		}
		if ( $this->verification_enabled && $this->verification ) {
			add_action( 'admin_post_content_miner_start_verification', array( $this, 'handle_start_verification' ) );
			add_action( 'admin_post_content_miner_process_verification', array( $this, 'handle_process_verification' ) );
		}
		if ( $this->semantic_enabled && $this->openai ) {
			add_action( 'admin_post_content_miner_save_openai_key', array( $this, 'handle_openai_key' ) );
		}
		if ( $this->google_enabled && $this->search_console && $this->analytics ) {
			add_action( 'admin_post_content_miner_save_google_credentials', array( $this, 'handle_google_credentials' ) );
			add_action( 'admin_post_content_miner_connect_google', array( $this, 'handle_google_connect' ) );
			add_action( 'admin_post_content_miner_google_callback', array( $this, 'handle_google_callback' ) );
			add_action( 'admin_post_content_miner_save_google_property', array( $this, 'handle_google_property' ) );
			add_action( 'admin_post_content_miner_sync_google', array( $this, 'handle_google_sync' ) );
			add_action( 'admin_post_content_miner_refresh_analytics_properties', array( $this, 'handle_analytics_properties' ) );
			add_action( 'admin_post_content_miner_save_analytics_property', array( $this, 'handle_analytics_property' ) );
			add_action( 'admin_post_content_miner_sync_analytics', array( $this, 'handle_analytics_sync' ) );
		}
		if ( $this->digest_enabled && $this->digest ) {
			add_action( 'admin_post_content_miner_save_digest', array( $this, 'handle_digest_settings' ) );
		}
		if ( $this->verdicts_enabled && $this->verdicts ) {
			add_action( 'admin_post_content_miner_explain_collisions', array( $this, 'handle_explain_collisions' ) );
		}
	}

	/** Register the top-level admin screen. */
	public function register_menu() {
		add_menu_page(
			__( 'Content Miner', 'content-miner' ),
			__( 'Content Miner', 'content-miner' ),
			'manage_options',
			self::PAGE_SLUG,
			array( $this, 'render_page' ),
			CONTENT_MINER_URL . 'assets/brand/mark.svg',
			58
		);
	}

	/**
	 * Load assets only on the plugin screen.
	 *
	 * @param string $hook_suffix Admin hook.
	 */
	public function enqueue_assets( $hook_suffix ) {
		wp_enqueue_style(
			'content-miner-admin-menu',
			CONTENT_MINER_URL . 'assets/admin-menu.css',
			array(),
			CONTENT_MINER_VERSION
		);

		if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook_suffix ) {
			return;
		}

		wp_enqueue_style(
			'content-miner-admin',
			CONTENT_MINER_URL . 'assets/admin.css',
			array(),
			file_exists( CONTENT_MINER_DIR . 'assets/admin.css' ) ? CONTENT_MINER_VERSION . '.' . filemtime( CONTENT_MINER_DIR . 'assets/admin.css' ) : CONTENT_MINER_VERSION
		);
		wp_enqueue_script(
			'content-miner-admin',
			CONTENT_MINER_URL . 'assets/admin.js',
			array(),
			file_exists( CONTENT_MINER_DIR . 'assets/admin.js' ) ? CONTENT_MINER_VERSION . '.' . filemtime( CONTENT_MINER_DIR . 'assets/admin.js' ) : CONTENT_MINER_VERSION,
			true
		);
		wp_localize_script(
			'content-miner-admin',
			'contentMinerPrompts',
			array(
				'copied'     => __( 'Copied — paste into ChatGPT, Claude, or any AI', 'content-miner' ),
				'copyFailed' => __( 'Copy failed. Select the prompt text and copy it manually.', 'content-miner' ),
			)
		);
		wp_localize_script(
			'content-miner-admin',
			'contentMinerPipeline',
			array(
				'ajaxUrl' => admin_url( 'admin-ajax.php' ),
				'nonce'   => wp_create_nonce( 'content_miner_pipeline_tick' ),
				'intervalMs' => 3000,
			)
		);
	}

	/** Render the dashboard. */
	public function render_page() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to view Content Miner.', 'content-miner' ) );
		}

		$scan        = get_option( self::SCAN_OPTION, array() );
		$locations   = $this->get_locations();
		$idea_result = get_transient( $this->idea_result_key() );
		$memory_summary = $this->memory->get_summary();
		$ingestion_state = $this->ingestion->get_state();
		$recent_changes = $this->memory->get_recent_changes();
		$family_labels = $this->memory->get_family_labels();
		$readiness_issue_labels = $this->readiness->get_issue_labels();
		$scoring_reason_labels = class_exists( 'Content_Miner_Scoring_Exclusions' ) ? Content_Miner_Scoring_Exclusions::reason_labels() : array();
		$source_total = $this->repository->get_total_count();
		$source_total_available = $this->repository->get_uncapped_total_count();
		$license_state = function_exists( 'content_miner_license' ) ? content_miner_license()->get_state( $source_total_available ) : array();
		$free_tier_state = $this->free_scan_limit
			? $this->free_scan_limit->get_state()
			: array( 'is_pro' => true, 'used' => 0, 'limit' => 3, 'remaining' => 3, 'exhausted' => false, 'activated' => true, 'can_sync' => true, 'can_start_analysis' => true, 'email' => '', 'subscription_status' => 'not_started', 'upgrade_url' => isset( $license_state['upgrade_url'] ) ? $license_state['upgrade_url'] : '' );
		$search_console = $this->google_enabled && $this->search_console ? $this->search_console->get_status() : array( 'connected' => false, 'configured' => false );
		$analytics      = $this->google_enabled && $this->analytics ? $this->analytics->get_status() : array( 'connected' => false );
		$openai         = $this->semantic_enabled && $this->openai ? $this->openai->get_status() : array( 'configured' => false, 'vault_available' => false, 'model' => '', 'dimensions' => 0, 'chat_model' => '' );
		$cloud_connector = $this->cloud_connector ? $this->cloud_connector->get_state() : array( 'connected' => false, 'endpoint' => CONTENT_MINER_CLOUD_DEFAULT_ENDPOINT );
		$pro_enabled    = $this->pro_enabled;
		$google_enabled = $this->google_enabled;
		$semantic_enabled = $this->semantic_enabled;
		$outcomes_enabled = $this->outcomes_enabled;
		$digest_enabled = $this->digest_enabled;
		$verification_enabled = $this->verification_enabled;
		$ai_explain_enabled = $this->ai_explain_enabled;
		$prompt_builder = $this->prompt_builder;
		$consultation = $this->consultation;
		$ai_explanation = get_transient( $this->ai_explanation_key() );
		$ai_explanation = is_array( $ai_explanation ) ? $ai_explanation : array();
		if ( $ai_explanation ) {
			delete_transient( $this->ai_explanation_key() );
		}
		$analysis_state = $this->analysis->get_state();
		$analysis_handoff_pending = $this->analysis_handoff ? $this->analysis_handoff->is_pending() : false;
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- These are read-only report filters; render_page() requires manage_options and performs no mutation.
		$inventory_search = isset( $_GET['cm_search'] ) ? sanitize_text_field( wp_unslash( $_GET['cm_search'] ) ) : '';
		$inventory_page = isset( $_GET['cm_inventory_page'] ) ? max( 1, absint( $_GET['cm_inventory_page'] ) ) : 1;
		$analysis_page = $this->memory->get_analysis_page( $inventory_page, 25, $inventory_search, $this->repository->get_max_id() );
		$ledger_page_number = isset( $_GET['cm_ledger_page'] ) ? max( 1, absint( $_GET['cm_ledger_page'] ) ) : 1;
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
		$ledger_page = $this->ledger->get_page( $ledger_page_number, 25 );
		$page_decisions = $this->ledger->get_page_decisions( wp_list_pluck( $analysis_page['items'], 'id' ) );
		$pair_decisions = $this->ledger->get_pair_decisions( isset( $scan['pairs'] ) ? $scan['pairs'] : array() );
		$outcomes = $this->outcomes_enabled ? $this->ledger->get_completed_outcomes( 25 ) : array();
		$digest_settings = $this->digest_enabled && $this->digest ? $this->digest->get_settings() : array( 'enabled' => false, 'email' => '' );
		$verdict_request_count = $this->verdicts_enabled && $this->verdicts ? $this->verdicts->count_eligible( is_array( $scan ) ? $scan : array() ) : 0;
		$verification_state = $this->verification ? $this->verification->get_state() : array(
			'status'    => 'disabled',
			'total'     => 0,
			'processed' => 0,
			'flagged'   => 0,
		);
		$report_prompt_page_loader = function ( array $search_status ) {
			return $this->memory->get_prompt_pages(
				$search_status,
				$this->repository->get_max_id()
			);
		};
		$report_prompt_search_evidence_count = $this->memory->count_prompt_pages_with_current_search(
			$search_console,
			$this->repository->get_max_id()
		);

		if ( false !== $idea_result ) {
			delete_transient( $this->idea_result_key() );
		}

		include CONTENT_MINER_DIR . 'views/admin-page.php';
	}

	/*
	 * PHPCS cannot infer nonce verification through guard_action(). Every
	 * admin-post handler below calls it before reading request data. The OAuth
	 * callback is the exception: it requires manage_options and exchange_code()
	 * verifies the single-use OAuth state before exchanging the code.
	 */
	// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended

	/** Save the site-owned Google Web application credentials. */
	public function handle_google_credentials() {
		$this->guard_action( 'content_miner_save_google_credentials' );
		$client_id     = isset( $_POST['client_id'] ) ? sanitize_text_field( wp_unslash( $_POST['client_id'] ) ) : '';
		$client_secret = isset( $_POST['client_secret'] ) ? sanitize_text_field( wp_unslash( $_POST['client_secret'] ) ) : '';
		$result        = $this->search_console->save_credentials( $client_id, $client_secret );
		if ( true === $result ) {
			delete_option( 'content_miner_ga4_status' );
		}
		$this->handle_connector_result( $result, 'google-credentials-saved' );
	}

	/** Redirect an administrator to Google's read-only consent screen. */
	public function handle_google_connect() {
		$this->guard_action( 'content_miner_connect_google' );
		$url = $this->search_console->get_authorization_url();
		if ( is_wp_error( $url ) ) {
			$this->store_connector_error( $url );
			$this->redirect_with_notice( 'google-error', '#connections' );
		}
		// phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- The authorization URL is generated for Google's fixed OAuth host; wp_safe_redirect() would reject the intentional external handoff.
		wp_redirect( $url );
		exit;
	}

	/** Complete Google's authorization-code callback after validating OAuth state. */
	public function handle_google_callback() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'Sign in to WordPress as an administrator, then reconnect Google.', 'content-miner' ) );
		}
		if ( isset( $_GET['error'] ) ) {
			/* translators: %s: sanitized OAuth error returned by Google. */
			$message = sprintf( __( 'Google authorization failed: %s', 'content-miner' ), sanitize_text_field( wp_unslash( $_GET['error'] ) ) );
			$this->store_connector_error( new WP_Error( 'content_miner_google_denied', $message ) );
			$this->redirect_with_notice( 'google-error', '#connections' );
		}

		$code   = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : '';
		$state  = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
		$result = $this->search_console->exchange_code( $code, $state );
		if ( ! is_wp_error( $result ) && $this->search_console->has_scope( 'https://www.googleapis.com/auth/analytics.readonly' ) ) {
			$analytics_result = $this->analytics->refresh_properties();
			if ( is_wp_error( $analytics_result ) ) {
				$this->analytics->store_error( $analytics_result );
			}
		}
		$this->handle_connector_result( $result, 'google-connected' );
	}

	/** Select the Search Console property used by this site. */
	public function handle_google_property() {
		$this->guard_action( 'content_miner_save_google_property' );
		$property = isset( $_POST['property'] ) ? sanitize_text_field( wp_unslash( $_POST['property'] ) ) : '';
		$result   = $this->search_console->save_property( $property );
		$this->handle_connector_result( $result, 'google-property-saved' );
	}

	/** Import the latest Search Console page evidence. */
	public function handle_google_sync() {
		$this->guard_action( 'content_miner_sync_google' );
		$status = $this->search_console->get_status();
		$result = 'running' === $status['sync_status'] ? $this->search_console->sync() : $this->search_console->start_sync();
		$this->handle_connector_result( $result, 'google-synced' );
	}

	/** Refresh the GA4 properties available to the connected account. */
	public function handle_analytics_properties() {
		$this->guard_action( 'content_miner_refresh_analytics_properties' );
		$this->handle_connector_result( $this->analytics->refresh_properties(), 'analytics-properties-refreshed', 'analytics' );
	}

	/** Select the GA4 property used by this site. */
	public function handle_analytics_property() {
		$this->guard_action( 'content_miner_save_analytics_property' );
		$property = isset( $_POST['property'] ) ? sanitize_text_field( wp_unslash( $_POST['property'] ) ) : '';
		$this->handle_connector_result( $this->analytics->save_property( $property ), 'analytics-property-saved', 'analytics' );
	}

	/** Import the latest GA4 landing-page evidence. */
	public function handle_analytics_sync() {
		$this->guard_action( 'content_miner_sync_analytics' );
		$status = $this->analytics->get_status();
		$result = 'running' === $status['sync_status'] ? $this->analytics->sync() : $this->analytics->start_sync();
		$this->handle_connector_result( $result, 'analytics-synced', 'analytics' );
	}

	/** Persist a page-specific re-inclusion or return it to automatic detection. */
	public function handle_scoring_override() {
		$this->guard_action( 'content_miner_set_scoring_override' );
		$ingestion_state = $this->ingestion->get_state();
		$analysis_state = $this->analysis->get_state();
		if ( self::scoring_override_is_blocked( $ingestion_state, $analysis_state ) ) {
			$this->redirect_with_notice( 'scoring-override-sync-required', '#inventory' );
		}
		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$mode = isset( $_POST['scoring_mode'] ) ? sanitize_key( wp_unslash( $_POST['scoring_mode'] ) ) : '';
		$item = in_array( $mode, array( 'include', 'automatic' ), true ) ? $this->repository->get_item( $post_id ) : null;
		if ( ! $item || ! $this->scoring_exclusions->set_override( $post_id, $mode ) ) {
			$this->redirect_with_notice( 'scoring-override-error', '#inventory' );
		}

		$refreshed = $this->repository->get_item( $post_id );
		if ( $refreshed ) {
			$this->memory->remember_items( array( $refreshed ) );
		}
		$return_args = array();
		$inventory_page = isset( $_POST['cm_inventory_page'] ) ? max( 1, absint( $_POST['cm_inventory_page'] ) ) : 1;
		$inventory_search = isset( $_POST['cm_search'] ) ? sanitize_text_field( wp_unslash( $_POST['cm_search'] ) ) : '';
		if ( $inventory_page > 1 ) {
			$return_args['cm_inventory_page'] = $inventory_page;
		}
		if ( '' !== $inventory_search ) {
			$return_args['cm_search'] = $inventory_search;
		}
		$this->redirect_with_notice( 'scoring-override-saved', self::decision_anchor( $post_id ), $return_args );
	}

	/** Record an explicit page or collision decision. */
	public function handle_decision() {
		$this->guard_action( 'content_miner_record_decision' );
		$post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;
		$pair_post_id = isset( $_POST['pair_post_id'] ) ? absint( $_POST['pair_post_id'] ) : 0;
		$decision = isset( $_POST['decision'] ) ? sanitize_key( wp_unslash( $_POST['decision'] ) ) : '';
		$note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : '';
		$return_args = array();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$inventory_page = isset( $_POST['cm_inventory_page'] ) ? max( 1, absint( $_POST['cm_inventory_page'] ) ) : 1;
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$collision_page = isset( $_POST['cm_collision_page'] ) ? max( 1, absint( $_POST['cm_collision_page'] ) ) : 1;
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$inventory_search = isset( $_POST['cm_search'] ) ? sanitize_text_field( wp_unslash( $_POST['cm_search'] ) ) : '';
		if ( $inventory_page > 1 ) {
			$return_args['cm_inventory_page'] = $inventory_page;
		}
		if ( $collision_page > 1 ) {
			$return_args['cm_collision_page'] = $collision_page;
		}
		if ( '' !== $inventory_search ) {
			$return_args['cm_search'] = $inventory_search;
		}
		$anchor = self::decision_anchor( $post_id, $pair_post_id );
		$result = $this->ledger->record( $post_id, $pair_post_id, $decision, $note, get_current_user_id() );
		if ( is_wp_error( $result ) ) {
			$this->redirect_with_notice( 'decision-error', $anchor, $return_args );
		}
		$this->redirect_with_notice( 'decision-saved', $anchor, $return_args );
	}

	/** Return the stable DOM anchor for a page or collision decision. @return string */
	public static function decision_anchor( $post_id, $pair_post_id = 0 ) {
		$post_id = absint( $post_id );
		$pair_post_id = absint( $pair_post_id );
		if ( $pair_post_id ) {
			return '#cm-pair-' . min( $post_id, $pair_post_id ) . '-' . max( $post_id, $pair_post_id );
		}
		return '#cm-page-' . $post_id;
	}

	/** Build a validated console return URL for form handlers. @return string */
	public static function report_return_url( $notice, $anchor = '', array $query_args = array() ) {
		$url = add_query_arg(
			array_merge(
				array(
					'page'      => self::PAGE_SLUG,
					'cm_notice' => sanitize_key( $notice ),
				),
				$query_args
			),
			admin_url( 'admin.php' )
		);

		return $url . $anchor;
	}

	/** Save the opt-in monthly digest settings. */
	public function handle_digest_settings() {
		$this->guard_action( 'content_miner_save_digest' );
		$enabled = ! empty( $_POST['digest_enabled'] );
		$email = isset( $_POST['digest_email'] ) ? sanitize_email( wp_unslash( $_POST['digest_email'] ) ) : '';
		$result = $this->digest->save_settings( $enabled, $email );
		$this->redirect_with_notice( is_wp_error( $result ) ? 'digest-error' : 'digest-saved', '#settings' );
	}

	/** Explain eligible semantic collisions after an explicit operator action. */
	public function handle_explain_collisions() {
		$this->guard_action( 'content_miner_explain_collisions' );
		$scan = get_option( self::SCAN_OPTION, array() );
		if ( is_array( $scan ) ) {
			update_option( self::SCAN_OPTION, $this->verdicts->explain( $scan ), false );
		}
		$this->redirect_with_notice( 'collisions-explained', '#collisions' );
	}

	/** Explain one portable prompt after an explicit premium operator action. */
	public function handle_explain_prompt() {
		$this->guard_action( 'content_miner_explain_prompt' );
		$target = isset( $_POST['prompt_target'] ) ? sanitize_key( wp_unslash( $_POST['prompt_target'] ) ) : 'report';
		$prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['prompt'] ) ) : '';
		$result = $this->ai_explain_enabled && $this->openai
			? $this->openai->explain_prompt( $prompt )
			: new WP_Error( 'content_miner_ai_explain_unavailable', __( 'Inline AI explanation is not available on the current plan. Copy the prompt and use it with your preferred AI instead.', 'content-miner' ) );
		set_transient(
			$this->ai_explanation_key(),
			array(
				'target' => $target,
				'result' => is_wp_error( $result ) ? '' : $result,
				'error'  => is_wp_error( $result ) ? $result->get_error_message() : '',
			),
			5 * MINUTE_IN_SECONDS
		);
		$return_args = array();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$inventory_page = isset( $_POST['cm_inventory_page'] ) ? max( 1, absint( $_POST['cm_inventory_page'] ) ) : 1;
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$collision_page = isset( $_POST['cm_collision_page'] ) ? max( 1, absint( $_POST['cm_collision_page'] ) ) : 1;
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard_action() verifies the action nonce before reading return context.
		$inventory_search = isset( $_POST['cm_search'] ) ? sanitize_text_field( wp_unslash( $_POST['cm_search'] ) ) : '';
		if ( $inventory_page > 1 ) {
			$return_args['cm_inventory_page'] = $inventory_page;
		}
		if ( $collision_page > 1 ) {
			$return_args['cm_collision_page'] = $collision_page;
		}
		if ( '' !== $inventory_search ) {
			$return_args['cm_search'] = $inventory_search;
		}
		$this->redirect_with_notice( is_wp_error( $result ) ? 'ai-explain-error' : 'ai-explained', '#cm-ai-' . $target, $return_args );
	}

	/** Download the current report as a revüe review workflow bundle. */
	public function handle_download_review_workflow() {
		$this->guard_action( 'content_miner_download_review_workflow' );
		$context = $this->current_report_export_context();
		if ( empty( $context['current'] ) ) {
			wp_die( esc_html__( 'The review workflow is available after a current analysis completes.', 'content-miner' ) );
		}
		$bundle = new Content_Miner_Revue_Workflow_Bundle();
		$files = $bundle->workflow_files();
		$files['review-workflow/README.md'] = $bundle->build_readme();
		$files['review-workflow/site-brief.md'] = $bundle->build_site_brief( $context['report'], $context['pages'], $this->readiness->get_issue_labels() );
		$zip = $bundle->build_zip( $files );

		nocache_headers();
		header( 'Content-Type: application/zip' );
		header( 'Content-Disposition: attachment; filename="content-miner-review-workflow.zip"' );
		header( 'Content-Length: ' . strlen( $zip ) );
		echo $zip; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary ZIP response.
		exit;
	}

	/** @return array<string,mixed> */
	private function current_report_export_context() {
		$scan = get_option( self::SCAN_OPTION, array() );
		$scan = is_array( $scan ) ? $scan : array();
		$memory_summary = $this->memory->get_summary();
		$ingestion_state = $this->ingestion->get_state();
		$analysis_state = $this->analysis->get_state();
		$source_total = $this->repository->get_total_count();
		$max_post_id = $this->repository->get_max_id();
		$memory_indexed = (int) $memory_summary['indexed'];
		$scored_count = (int) $memory_summary['scored'];
		$excluded_count = (int) $memory_summary['excluded'];
		$memory_upgrade_needed = $memory_indexed > 0 && (int) $memory_summary['schema'] !== Content_Miner_Memory::MEMORY_SCHEMA;
		$full_rebuild_incomplete = 'full' === ( $ingestion_state['mode'] ?? '' ) && in_array( $ingestion_state['status'], array( 'queued', 'running', 'failed' ), true );
		$memory_population_current = $memory_indexed === $scored_count + $excluded_count;
		$scored_aggregates_current = ! $memory_upgrade_needed && ! $full_rebuild_incomplete && $memory_population_current;
		$scan_population_current = isset( $scan['item_count'], $scan['summary']['scored'], $scan['summary']['excluded'] )
			&& (int) $scan['item_count'] === $memory_indexed
			&& (int) $scan['summary']['scored'] === $scored_count
			&& (int) $scan['summary']['excluded'] === $excluded_count;
		$current = $scored_aggregates_current && $scan_population_current && ! in_array( $analysis_state['status'], array( 'queued', 'running', 'failed' ), true );
		$readiness_count = (int) $memory_summary['readiness_count'];
		$readiness_scores = array();
		foreach ( array( 'seo', 'aeo', 'geo', 'aio' ) as $lens ) {
			$readiness_scores[ $lens ] = $readiness_count > 0 ? (int) round( $memory_summary['readiness_sums'][ $lens ] / $readiness_count ) : 0;
		}
		$issue_labels = $this->readiness->get_issue_labels();
		$report_issues = array();
		$readiness_issues = isset( $memory_summary['readiness_issues'] ) && is_array( $memory_summary['readiness_issues'] ) ? $memory_summary['readiness_issues'] : array();
		arsort( $readiness_issues );
		foreach ( $readiness_issues as $issue_code => $issue_count ) {
			$report_issues[ isset( $issue_labels[ $issue_code ] ) ? $issue_labels[ $issue_code ] : $issue_code ] = $issue_count;
		}
		$pages = array();
		if ( $current ) {
			$page = 1;
			do {
				$result = $this->memory->get_analysis_page( $page, 100, '', $max_post_id );
				foreach ( $result['items'] as $item ) {
					if ( ! Content_Miner_Scoring_Exclusions::is_in_scope( $item ) ) {
						continue;
					}
					$pages[] = $item;
				}
				++$page;
			} while ( count( $pages ) < (int) $result['total'] );
		}
		return array(
			'current' => $current,
			'pages'   => $pages,
			'report'  => array(
				'source_total'     => $source_total,
				'memory_indexed'   => $memory_indexed,
				'summary'          => isset( $scan['summary'] ) && is_array( $scan['summary'] ) ? $scan['summary'] : array(),
				'scored_count'     => $scored_count,
				'excluded_count'   => $excluded_count,
				'input_sources'    => isset( $memory_summary['input_sources'] ) && is_array( $memory_summary['input_sources'] ) ? $memory_summary['input_sources'] : array(),
				'readiness_scores' => $readiness_scores,
				'top_issues'       => $report_issues,
			),
		);
	}

	/** Save tier-aware consultation settings. */
	public function handle_consultation_settings() {
		$this->guard_action( 'content_miner_save_consultation' );
		$license_state = function_exists( 'content_miner_license' ) ? content_miner_license()->get_state() : array( 'plan' => 'free' );
		$enabled = ! empty( $_POST['consultation_enabled'] );
		$custom_url = isset( $_POST['consultation_url'] ) ? esc_url_raw( wp_unslash( $_POST['consultation_url'] ) ) : '';
		$label = isset( $_POST['consultation_label'] ) ? sanitize_text_field( wp_unslash( $_POST['consultation_label'] ) ) : '';
		$result = $this->consultation->save_settings( isset( $license_state['plan'] ) ? $license_state['plan'] : 'free', $enabled, $custom_url, $label );
		$this->redirect_with_notice( is_wp_error( $result ) ? 'consultation-error' : 'consultation-saved', '#settings' );
	}

	/** Register this WordPress install with Content Miner cloud. */
	public function handle_cloud_connect() {
		$this->guard_action( 'content_miner_cloud_connect' );
		if ( ! $this->cloud_connector ) {
			$this->redirect_with_notice( 'cloud-error', '#connections' );
		}
		$endpoint = isset( $_POST['cloud_endpoint'] ) ? esc_url_raw( wp_unslash( $_POST['cloud_endpoint'] ) ) : '';
		$api_token = isset( $_POST['cloud_api_token'] ) ? sanitize_text_field( wp_unslash( $_POST['cloud_api_token'] ) ) : '';
		$result = $this->cloud_connector->connect( $endpoint, $api_token );
		$this->redirect_with_notice( is_wp_error( $result ) ? 'cloud-error' : 'cloud-connected', '#connections' );
	}

	/** Upload the latest completed local report summary to Content Miner cloud. */
	public function handle_cloud_upload() {
		$this->guard_action( 'content_miner_cloud_upload' );
		if ( ! $this->cloud_connector ) {
			$this->redirect_with_notice( 'cloud-error', '#connections' );
		}
		$result = $this->cloud_connector->upload_current_report();
		$this->redirect_with_notice( is_wp_error( $result ) ? 'cloud-error' : 'cloud-uploaded', '#connections' );
	}

	/** Start the manual public-page verification job. */
	public function handle_start_verification() {
		$this->guard_action( 'content_miner_start_verification' );
		$this->verification->start();
		$this->redirect_with_notice( 'verification-started', '#inventory' );
	}

	/** Process one manual verification batch. */
	public function handle_process_verification() {
		$this->guard_action( 'content_miner_process_verification' );
		$this->verification->process_batch( 'manual' );
		$this->redirect_with_notice( 'verification-processed', '#inventory' );
	}

	/** Start the initial memory build or an incremental synchronization. */
	public function handle_start_ingestion() {
		$this->guard_action( 'content_miner_start_ingestion' );
		if ( $this->free_scan_limit && ! $this->free_scan_limit->can_sync() ) {
			$this->redirect_with_notice( 'free-limit-reached', '#overview' );
		}

		$summary = $this->memory->get_summary();
		$previous = $this->ingestion->get_state();
		$full_rebuild_failed = 'failed' === $previous['status'] && 'full' === ( isset( $previous['mode'] ) ? $previous['mode'] : '' );
		$mode = empty( $summary['indexed'] )
			|| (int) $summary['schema'] !== Content_Miner_Memory::MEMORY_SCHEMA
			|| get_option( Content_Miner_Ingestion::VECTOR_BACKFILL_OPTION, false )
			|| $full_rebuild_failed
			? 'full'
			: 'incremental';
		$this->ingestion->start( $mode );
		$this->redirect_with_notice( 'memory-started', '#overview' );
	}

	/** Process a single batch immediately while background processing continues. */
	public function handle_process_batch() {
		$this->guard_action( 'content_miner_process_batch' );

		$this->ingestion->process_batch( 'manual' );
		$this->redirect_with_notice( 'batch-processed', '#overview' );
	}

	/** Run and store a manual scan. */
	public function handle_scan() {
		$this->guard_action( 'content_miner_run_scan' );
		if ( $this->free_scan_limit ) {
			$free_tier_state = $this->free_scan_limit->get_state();
			if ( ! empty( $free_tier_state['exhausted'] ) ) {
				$this->redirect_with_notice( 'free-limit-reached', '#inventory' );
			}
			if ( empty( $free_tier_state['can_start_analysis'] ) ) {
				$this->redirect_with_notice( 'free-activation-required', '#free-analyses' );
			}
		}
		$summary = $this->memory->get_summary();
		$ingestion_state = $this->ingestion->get_state();
		$full_rebuild_incomplete = 'full' === ( isset( $ingestion_state['mode'] ) ? $ingestion_state['mode'] : '' )
			&& in_array( isset( $ingestion_state['status'] ) ? $ingestion_state['status'] : '', array( 'queued', 'running', 'failed' ), true );
		$ingestion_active = in_array( isset( $ingestion_state['status'] ) ? $ingestion_state['status'] : '', array( 'queued', 'running' ), true );
		$requires_full_sync = (int) $summary['indexed'] < $this->repository->get_total_count()
			|| (int) $summary['schema'] !== Content_Miner_Memory::MEMORY_SCHEMA
			|| get_option( Content_Miner_Ingestion::VECTOR_BACKFILL_OPTION, false )
			|| $full_rebuild_incomplete;
		if ( $requires_full_sync || $this->memory->get_dirty_count() > 0 ) {
			if ( $this->analysis_handoff ) {
				$this->analysis_handoff->queue( $this->get_locations() );
			}
			if ( ! $ingestion_active ) {
				$this->ingestion->start( $requires_full_sync ? 'full' : 'incremental' );
			}
			$this->redirect_with_notice( 'analysis-waiting-for-sync', '#overview' );
		}
		if ( $this->analysis_handoff ) {
			$this->analysis_handoff->clear();
		}
		$this->analysis->start( $this->get_locations() );
		$this->redirect_with_notice( 'scan-started', '#inventory' );
	}

	/** Store free-tier consent locally and deliver it without blocking analysis. */
	public function handle_activate_free_analyses() {
		$this->guard_action( 'content_miner_activate_free_analyses' );
		if ( ! $this->free_scan_limit ) {
			$this->redirect_with_notice( 'free-activation-required', '#free-analyses' );
		}
		$email  = isset( $_POST['activation_email'] ) ? sanitize_email( wp_unslash( $_POST['activation_email'] ) ) : '';
		$result = $this->free_scan_limit->activate( $email, 'plugin_form' );
		$this->redirect_with_notice( is_wp_error( $result ) ? 'free-activation-invalid' : 'free-activation-saved', '#free-analyses' );
	}

	/** Process one full-library analysis batch immediately. */
	public function handle_process_analysis() {
		$this->guard_action( 'content_miner_process_analysis' );
		$this->analysis->process_batch( 'manual' );
		$this->redirect_with_notice( 'analysis-batch-processed', '#inventory' );
	}

	/** Process one foreground pipeline batch from the open admin report. */
	public function handle_pipeline_tick() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'You do not have permission to manage Content Miner.', 'content-miner' ) ), 403 );
		}
		check_ajax_referer( 'content_miner_pipeline_tick', 'nonce' );

		$processed = 'none';
		$ingestion_state = $this->ingestion->get_state();
		$verification_state = $this->verification ? $this->verification->get_state() : array( 'status' => 'disabled' );
		$analysis_state = $this->analysis->get_state();

		if ( in_array( isset( $ingestion_state['status'] ) ? $ingestion_state['status'] : '', array( 'queued', 'running' ), true ) ) {
			$ingestion_state = $this->ingestion->process_batch( 'foreground' );
			$processed = 'sync';
			$verification_state = $this->verification ? $this->verification->get_state() : $verification_state;
			$analysis_state = $this->analysis->get_state();
		} elseif ( $this->verification && in_array( isset( $verification_state['status'] ) ? $verification_state['status'] : '', array( 'queued', 'running' ), true ) ) {
			$verification_state = $this->verification->process_batch( 'foreground' );
			$processed = 'verification';
		} elseif ( in_array( isset( $analysis_state['status'] ) ? $analysis_state['status'] : '', array( 'queued', 'running' ), true ) ) {
			$analysis_state = $this->analysis->process_batch( 'foreground' );
			$processed = 'analysis';
		}

		$active = in_array( isset( $ingestion_state['status'] ) ? $ingestion_state['status'] : '', array( 'queued', 'running' ), true )
			|| in_array( isset( $verification_state['status'] ) ? $verification_state['status'] : '', array( 'queued', 'running' ), true )
			|| in_array( isset( $analysis_state['status'] ) ? $analysis_state['status'] : '', array( 'queued', 'running' ), true )
			|| ( $this->analysis_handoff && $this->analysis_handoff->is_pending() );

		wp_send_json_success(
			array(
				'processed' => $processed,
				'active'    => $active,
			)
		);
	}

	/** Validate and save the optional OpenAI key. */
	public function handle_openai_key() {
		$this->guard_action( 'content_miner_save_openai_key' );
		$key = isset( $_POST['openai_api_key'] ) ? sanitize_text_field( wp_unslash( $_POST['openai_api_key'] ) ) : '';
		$key = is_string( $key ) ? $key : '';
		$chat_model = isset( $_POST['openai_chat_model'] ) ? sanitize_text_field( wp_unslash( $_POST['openai_chat_model'] ) ) : 'gpt-4o-mini';
		$was_configured = ! empty( $this->openai->get_status()['configured'] );
		$result = $this->openai->save_api_key( $key );
		if ( is_wp_error( $result ) ) {
			set_transient( 'content_miner_openai_error_' . get_current_user_id(), $result->get_error_message(), MINUTE_IN_SECONDS );
			$this->redirect_with_notice( 'openai-error', '#connections' );
		}
		if ( '' === trim( $key ) ) {
			delete_option( Content_Miner_Ingestion::VECTOR_BACKFILL_OPTION );
		} elseif ( ! $was_configured ) {
			update_option( Content_Miner_Ingestion::VECTOR_BACKFILL_OPTION, true, false );
		}
		update_option( 'content_miner_openai_chat_model', $chat_model ? $chat_model : 'gpt-4o-mini', false );
		$this->redirect_with_notice( '' === trim( $key ) ? 'openai-disconnected' : 'openai-saved', '#connections' );
	}

	/** Check one proposed idea without storing the idea permanently. */
	public function handle_idea_check() {
		$this->guard_action( 'content_miner_check_idea' );

		$idea     = isset( $_POST['idea'] ) ? sanitize_textarea_field( wp_unslash( $_POST['idea'] ) ) : '';
		$location = isset( $_POST['location'] ) ? sanitize_text_field( wp_unslash( $_POST['location'] ) ) : '';

		if ( '' === trim( $idea ) ) {
			$this->redirect_with_notice( 'idea-required' );
		}

		$inventory = $this->repository->get_inventory();
		$result    = $this->analyzer->analyze_idea( $idea, $location, $inventory );

		set_transient( $this->idea_result_key(), $result, 5 * MINUTE_IN_SECONDS );
		$this->redirect_with_notice( 'idea-checked', '#idea-checker' );
	}

	/** Save markets used for localization signals. */
	public function handle_settings() {
		$this->guard_action( 'content_miner_save_settings' );

		$raw       = isset( $_POST['locations'] ) ? sanitize_textarea_field( wp_unslash( $_POST['locations'] ) ) : '';
		$locations = preg_split( '/[\r\n,]+/', $raw );
		$locations = array_values(
			array_unique(
				array_filter(
					array_map( 'trim', $locations )
				)
			)
		);

		update_option( self::LOCATIONS_OPTION, $locations, false );
		$this->redirect_with_notice( 'settings-saved', '#settings' );
	}

	// phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended

	/**
	 * @return string[]
	 */
	private function get_locations() {
		$locations = get_option( self::LOCATIONS_OPTION, array() );

		return is_array( $locations ) ? array_map( 'strval', $locations ) : array();
	}

	/**
	 * Remove source bodies before saving a scan. The dashboard needs decisions
	 * and signals, not a second stored copy of the content library.
	 *
	 * @param array<string,mixed> $scan Scan result.
	 * @return array<string,mixed>
	 */
	private function compact_scan( array $scan ) {
		if ( empty( $scan['items'] ) || ! is_array( $scan['items'] ) ) {
			return $scan;
		}

		foreach ( $scan['items'] as &$item ) {
			unset( $item['content'], $item['normalized'], $item['title_tokens'], $item['body_tokens'], $item['excerpt'], $item['signals'] );
		}
		unset( $item );

		return $scan;
	}

	/**
	 * Prevent population changes while a full rebuild or analysis can write stale totals.
	 *
	 * @param array<string,mixed> $ingestion_state Ingestion state.
	 * @param array<string,mixed> $analysis_state Analysis state.
	 * @return bool
	 */
	private static function scoring_override_is_blocked( array $ingestion_state, array $analysis_state ) {
		$full_rebuild_incomplete = 'full' === ( isset( $ingestion_state['mode'] ) ? $ingestion_state['mode'] : '' )
			&& in_array( isset( $ingestion_state['status'] ) ? $ingestion_state['status'] : '', array( 'queued', 'running', 'failed' ), true );
		$analysis_active = in_array( isset( $analysis_state['status'] ) ? $analysis_state['status'] : '', array( 'queued', 'running' ), true );
		return $full_rebuild_incomplete || $analysis_active;
	}

	/**
	 * Check authorization and nonce.
	 *
	 * @param string $action Nonce action.
	 */
	private function guard_action( $action ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to perform this action.', 'content-miner' ) );
		}

		check_admin_referer( $action );
	}

	/**
	 * @return string
	 */
	private function idea_result_key() {
		return 'content_miner_idea_' . get_current_user_id();
	}

	/** Return the current user's short-lived inline explanation key. @return string */
	private function ai_explanation_key() {
		return 'content_miner_ai_explanation_' . get_current_user_id();
	}

	/** @param mixed $result Connector result. @param string $success_notice Success notice. */
	private function handle_connector_result( $result, $success_notice, $connector = 'search' ) {
		if ( is_wp_error( $result ) ) {
			if ( 'analytics' === $connector ) {
				$this->analytics->store_error( $result );
			} else {
				$this->store_connector_error( $result );
			}
			$this->redirect_with_notice( 'google-error', '#connections' );
		}
		$this->redirect_with_notice( $success_notice, '#connections' );
	}

	/** @param WP_Error $error Connector error. */
	private function store_connector_error( $error ) {
		$status               = get_option( 'content_miner_gsc_status', array() );
		$status               = is_array( $status ) ? $status : array();
		$status['last_error'] = sanitize_text_field( $error->get_error_message() );
		update_option( 'content_miner_gsc_status', $status, false );
	}

	/**
	 * @param string $notice Notice code.
	 * @param string              $anchor Optional page anchor.
	 * @param array<string,mixed> $query_args Optional validated query arguments.
	 */
	private function redirect_with_notice( $notice, $anchor = '', array $query_args = array() ) {
		wp_safe_redirect( self::report_return_url( $notice, $anchor, $query_args ) );
		exit;
	}
}
