<?php

namespace Antimanual\Api;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Antimanual\PostGenerator;

/**
 * Generate Post API endpoints.
 */
class PostGeneratorController {
	/**
	 * Option name for storing prompt templates.
	 *
	 * @var string
	 */
	private const TEMPLATES_OPTION = 'atml_prompt_templates';

	/**
	 * Register REST routes for Generate Post.
	 *
	 * @param string $namespace REST namespace.
	 */
	public function register_routes( string $namespace ) {
		register_rest_route(
			$namespace,
			'/generate-post',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'generate_single_post' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 300,
			)
		);

		// Job status endpoint - returns queue status and result when available.
		register_rest_route(
			$namespace,
			'/generate-post/status',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'get_job_status' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'args'                => array(
					'job_id' => array(
						'required' => true,
						'sanitize_callback' => 'sanitize_text_field',
						'validate_callback' => function ( $param ) {
							// Accept UUID v4 format generated by wp_generate_uuid4() or any non-empty string
							if ( ! is_string( $param ) ) {
								return false;
							}
							// UUID v4 length is 36 (with hyphens). Allow general non-empty strings as fallback.
							if ( preg_match( '/^[0-9a-fA-F\-]{36}$/', $param ) ) {
								return true;
							}
							return strlen( trim( $param ) ) > 0;
						},
					),
				),
			)
		);

		// Internal worker dispatch endpoint - called by the server itself via a
		// non-blocking loopback request so jobs run independently of WP-Cron,
		// which is unreliable on local environments (LocalWP self-signed certs,
		// PHP-FPM session locks). Auth is via a per-job token stored alongside
		// the job, since the loopback request may not carry session cookies.
		register_rest_route(
			$namespace,
			'/generate-post/run-worker',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'run_worker' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'job_id' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'token'  => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/generated-posts',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_generated_posts' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 120,
				'args'                => array(
					'page'     => array(
						'required'          => false,
						'default'           => 1,
						'validate_callback' => function ( $param ) {
							return is_numeric( $param ) && intval( $param ) > 0;
						},
					),
					'per_page' => array(
						'required'          => false,
						'default'           => 10,
						'validate_callback' => function ( $param ) {
							return is_numeric( $param ) && intval( $param ) > 0 && intval( $param ) <= 100;
						},
					),
					'search'   => array(
						'required'          => false,
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'status'   => array(
						'required'          => false,
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'orderby'  => array(
						'required'          => false,
						'default'           => 'date',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'order'    => array(
						'required'          => false,
						'default'           => 'DESC',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/generated-posts/stats',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'get_generated_posts_stats' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
			)
		);

		register_rest_route(
			$namespace,
			'/generate-post/suggest-prompt',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'suggest_post_prompt' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 60,
			)
		);

			register_rest_route(
				$namespace,
				'/generate-post/kb-topics',
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'suggest_kb_topics' ),
					'permission_callback' => fn() => current_user_can( 'edit_posts' ),
					'timeout'             => 60,
					'args'                => array(
						'count' => array(
							'required'          => false,
							'default'           => 6,
							'validate_callback' => function ( $param ) {
								return is_numeric( $param ) && intval( $param ) >= 1 && intval( $param ) <= 20;
							},
						),
						'topic' => array(
							'required'          => false,
							'default'           => '',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				)
				);

		register_rest_route(
			$namespace,
			'/generate-post/enhance-prompt',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'enhance_post_prompt' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 60,
				'args'                => array(
					'prompt' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
				),
			)
		);

		// Prompt templates routes.
		register_rest_route(
			$namespace,
			'/generate-post/templates',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_templates' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
			)
		);

		register_rest_route(
			$namespace,
			'/generate-post/templates',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'save_template' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'args'                => array(
					'name'   => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'prompt' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
				),
			)
		);

		register_rest_route(
			$namespace,
			'/generate-post/templates/(?P<id>[a-zA-Z0-9_-]+)',
			array(
				'methods'             => 'DELETE',
				'callback'            => array( $this, 'delete_template' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
			)
		);

		// Keyword research route.
		register_rest_route(
			$namespace,
			'/keyword-research',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'research_keywords' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 60,
				'args'                => array(
					'topic'    => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'language' => array(
						'required'          => false,
						'default'           => 'English',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// Recent prompts route.
		register_rest_route(
			$namespace,
			'/generate-post/recent',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'list_recent_prompts' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'args'                => array(
					'limit' => array(
						'required'          => false,
						'default'           => 5,
						'validate_callback' => function ( $param ) {
							return is_numeric( $param ) && intval( $param ) > 0 && intval( $param ) <= 20;
						},
					),
				),
			)
		);

		// Outline generation route.
		register_rest_route(
			$namespace,
			'/generate-post/outline',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'generate_outline' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'timeout'             => 60,
				'args'                => array(
					'prompt'     => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_textarea_field',
					),
					'language'   => array(
						'required'          => false,
						'default'           => 'English',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'min_length' => array(
						'required'          => false,
						'default'           => 800,
						'validate_callback' => function ( $param ) {
							return is_numeric( $param );
						},
					),
					'max_length' => array(
						'required'          => false,
						'default'           => 1200,
						'validate_callback' => function ( $param ) {
							return is_numeric( $param );
						},
					),
					'pov'        => array(
						'required'          => false,
						'default'           => 'third_person',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'target_audience' => array(
						'required'          => false,
						'default'           => 'general',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// Lightweight rule-based intent classifier — used by the brief panel to
		// pre-fill the search-intent dropdown as the user types a topic.
		register_rest_route(
			$namespace,
			'/generate-post/intent',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'classify_intent' ),
				'permission_callback' => fn() => current_user_can( 'edit_posts' ),
				'args'                => array(
					'topic' => array(
						'required'          => true,
						'sanitize_callback' => 'sanitize_text_field',
					),
					'focus_keyword' => array(
						'required'          => false,
						'default'           => '',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		// PHP notices/warnings echoed during plugin load by other plugins (e.g.
		// "Block type names must not contain uppercase characters" from a third
		// party that registers a malformed block) get prepended to the response
		// body and break JSON parsing on the client. Discard any such stray
		// output right before WordPress echoes the JSON for our routes.
		$route_prefix = '/' . trim( $namespace, '/' ) . '/generate-post';
		add_filter(
			'rest_pre_echo_response',
			static function ( $result, $server, $request ) use ( $route_prefix ) {
				$route = $request->get_route();
				if ( is_string( $route ) && 0 === strpos( $route, $route_prefix ) ) {
					while ( ob_get_level() > 0 && ob_get_length() ) {
						ob_clean();
					}
				}
				return $result;
			},
			10,
			3
		);
	}

	/**
	 * Classify the search intent of a topic via heuristic regex patterns.
	 *
	 * Cheap (no LLM call). Used to pre-fill the brief-panel intent dropdown.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function classify_intent( $request ) {
		$topic = (string) $request->get_param( 'topic' );
		$focus = (string) ( $request->get_param( 'focus_keyword' ) ?? '' );

		if ( '' === trim( $topic ) ) {
			return rest_ensure_response( array(
				'success' => false,
				'message' => __( 'Topic is required.', 'antimanual' ),
			) );
		}

		$result = \Antimanual\ContentBriefService::classify_intent_heuristic( $topic, $focus );
		return rest_ensure_response( array(
			'success' => true,
			'data'    => $result,
		) );
	}

	/**
	 * Generate a single post with AI.
	 *
	 * @since 2.3.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function generate_single_post( $request ) {
		// Increase execution time limit for long-running AI requests.
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 600 );
		}
		// Prevent PHP notices/warnings from breaking the JSON response.
		@ini_set( 'display_errors', '0' );

		$files                  = $_FILES['files'] ?? array();
		$attachments            = $this->collect_uploaded_attachments( is_array( $files ) ? $files : array() );
		$inspiration_file       = $_FILES['featured_image_inspiration'] ?? array();
		$inspiration_attachments = $this->collect_uploaded_attachments( is_array( $inspiration_file ) ? $inspiration_file : array() );
		$featured_image_inspiration_attachment = ! empty( $inspiration_attachments ) ? intval( $inspiration_attachments[0] ) : 0;
		$status                 = sanitize_key( (string) ( $request->get_param( 'status' ) ?? 'draft' ) );
		$focus_keyword = $this->resolve_focus_keyword( $request );
		$scheduled_date = sanitize_text_field( (string) ( $request->get_param( 'scheduled_date' ) ?? '' ) );
		$faq_block_type_raw = sanitize_key( (string) ( $request->get_param( 'faq_block_type' ) ?? 'default' ) );
		$faq_block_type     = in_array( $faq_block_type_raw, array( 'default', 'advanced' ), true ) ? $faq_block_type_raw : 'default';

		if ( 'future' === $status ) {
			if ( empty( $scheduled_date ) ) {
				return rest_ensure_response(
					array(
						'success' => false,
						'message' => __( 'Scheduled date is required for future posts.', 'antimanual' ),
					)
				);
			}

			$scheduled_datetime = date_create( $scheduled_date );

			if ( ! ( $scheduled_datetime instanceof \DateTime ) ) {
				$scheduled_datetime = \DateTime::createFromFormat( 'Y-m-d H:i:s', $scheduled_date, wp_timezone() );
			}

			if ( ! ( $scheduled_datetime instanceof \DateTime ) ) {
				return rest_ensure_response(
					array(
						'success' => false,
						'message' => __( 'Invalid scheduled date format.', 'antimanual' ),
					)
				);
			}

			$scheduled_datetime->setTimezone( wp_timezone() );
			$scheduled_date = $scheduled_datetime->format( 'Y-m-d H:i:s' );
		}

		$params = array(
			'prompt'                            => sanitize_textarea_field( $request->get_param( 'prompt' ) ?? '' ),
			'tone'                              => sanitize_text_field( $request->get_param( 'tone' ) ?? '' ),
			'language'                          => sanitize_text_field( $request->get_param( 'language' ) ?? 'English' ),
			'min_length'                        => intval( $request->get_param( 'min_length' ) ?? 800 ),
			'max_length'                        => intval( $request->get_param( 'max_length' ) ?? 1200 ),
			'slug_language'                     => sanitize_text_field( $request->get_param( 'slug_language' ) ?? 'English' ),
			'slug_max_length'                   => intval( $request->get_param( 'slug_max_length' ) ?? 50 ),
			'post_type'                         => sanitize_text_field( $request->get_param( 'post_type' ) ?? 'post' ),
			'author'                            => intval( $request->get_param( 'author' ) ?? get_current_user_id() ),
			'status'                            => $status,
			'generate_taxonomies'               => filter_var( $request->get_param( 'generate_taxonomies' ) ?? true, FILTER_VALIDATE_BOOLEAN ),
			'taxonomy_count'                    => intval( $request->get_param( 'taxonomy_count' ) ?? 3 ),
			'taxonomy_settings'                 => sanitize_text_field( (string) ( $request->get_param( 'taxonomy_settings' ) ?? '' ) ),
			'parent'                            => intval( $request->get_param( 'parent' ) ?? 0 ),
			'generate_excerpt'                  => filter_var( $request->get_param( 'generate_excerpt' ) ?? true, FILTER_VALIDATE_BOOLEAN ),
			'excerpt_length'                    => intval( $request->get_param( 'excerpt_length' ) ?? 50 ),
			'attachments'                       => $attachments,
			'focus_keyword'                     => $focus_keyword,
			'scheduled_date'                    => $scheduled_date,
			'use_existing_knowledge'            => filter_var( $request->get_param( 'use_existing_knowledge' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'generate_meta_description'         => filter_var( $request->get_param( 'generate_meta_description' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'optimize_for_seo'                  => filter_var( $request->get_param( 'optimize_for_seo' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'generate_featured_image'           => filter_var( $request->get_param( 'generate_featured_image' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'use_post_title_in_featured_image'  => filter_var( $request->get_param( 'use_post_title_in_featured_image' ) ?? true, FILTER_VALIDATE_BOOLEAN ),
			'featured_image_inspiration_attachment' => $featured_image_inspiration_attachment,
			'include_image_caption'             => filter_var( $request->get_param( 'include_image_caption' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'suggest_internal_links'            => filter_var( $request->get_param( 'suggest_internal_links' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'show_internal_links_pro_tip'       => filter_var( $request->get_param( 'show_internal_links_pro_tip' ) ?? true, FILTER_VALIDATE_BOOLEAN ),
			'internal_links_pro_tip_label'      => sanitize_text_field( $request->get_param( 'internal_links_pro_tip_label' ) ?? $request->get_param( 'internal_links_pro_tip_text' ) ?? '' ),
			'internal_links_pro_tip_bg_color'   => sanitize_hex_color( (string) ( $request->get_param( 'internal_links_pro_tip_bg_color' ) ?? '' ) ) ?: '#f5f1dd',
			'include_toc'                       => filter_var( $request->get_param( 'include_toc' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'include_faq'                       => filter_var( $request->get_param( 'include_faq' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'faq_block_type'                    => $faq_block_type,
			'pov'                               => sanitize_text_field( $request->get_param( 'pov' ) ?? 'third_person' ),
			'target_audience'                   => sanitize_text_field( $request->get_param( 'target_audience' ) ?? 'general' ),
			'include_conclusion'                => filter_var( $request->get_param( 'include_conclusion' ) ?? true, FILTER_VALIDATE_BOOLEAN ),
			'include_content_images'            => filter_var( $request->get_param( 'include_content_images' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'tone_is_custom'                    => filter_var( $request->get_param( 'tone_is_custom' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'humanize_content'                  => filter_var( $request->get_param( 'humanize_content' ) ?? false, FILTER_VALIDATE_BOOLEAN ),
			'custom_outline'                    => wp_unslash( (string) ( $request->get_param( 'custom_outline' ) ?? '' ) ),
			'content_brief'                     => wp_unslash( (string) ( $request->get_param( 'content_brief' ) ?? '' ) ),
		);

		$job_id         = wp_generate_uuid4();
		$dispatch_token = wp_generate_password( 32, false, false );
		$safe_params    = array_merge(
			$params,
			array(
				'prompt'           => substr( $params['prompt'] ?? '', 0, 10000 ),
				'attachments'      => array_map( 'intval', array_slice( $params['attachments'] ?? [], 0, 10 ) ),
				'content_brief'    => substr( (string) ( $params['content_brief'] ?? '' ), 0, 30000 ),
				'__dispatch_token' => $dispatch_token,
			)
		);

		\Antimanual\GenerationJobs::insert( $job_id, $safe_params );

		// Schedule via WP-Cron as the outermost safety net.
		if ( ! wp_next_scheduled( 'atml_process_generation_job', array( $job_id ) ) ) {
			wp_schedule_single_event( time() + 1, 'atml_process_generation_job', array( $job_id ) );
		}

		// Primary dispatch: fire-and-forget loopback HTTP request. Runs in a
		// separate PHP process so the user can keep using the admin while
		// generation happens. On hosts where loopback actually works this is
		// the only dispatch path needed.
		$this->dispatch_worker_http( $job_id, $dispatch_token );

		// Secondary dispatch: on local dev (LocalWP, Valet, MAMP) the loopback
		// request often dies in DNS / self-signed-SSL. Schedule the worker to
		// also run in *this* PHP process after the response has been flushed.
		// Costs nothing if the loopback already claimed the job — the worker
		// short-circuits when it sees status='processing'.
		$this->schedule_inline_worker_after_response( $job_id );

		return rest_ensure_response( array( 'success' => true, 'data' => array( 'job_id' => $job_id ) ) );
	}

	/**
	 * Queue a post-response handler that runs the generation worker in the
	 * current PHP process after the HTTP response has been flushed. Combined
	 * with the loopback dispatch this guarantees the job runs even when
	 * loopback HTTP requests fail (common on local environments).
	 *
	 * @param string $job_id
	 * @return void
	 */
	private function schedule_inline_worker_after_response( string $job_id ): void {
		// Snapshot a small marker so the shutdown handler only fires for the
		// expected job and isn't accidentally invoked twice.
		static $registered = array();
		if ( isset( $registered[ $job_id ] ) ) {
			return;
		}
		$registered[ $job_id ] = true;

		add_action( 'shutdown', function () use ( $job_id ) {
			// Flush the response to the client first so the browser gets job_id
			// back and starts polling without waiting on generation.
			if ( function_exists( 'fastcgi_finish_request' ) ) {
				@fastcgi_finish_request();
			} else {
				// Non-FPM fallback: try to close the connection via headers and
				// flush buffers. Best-effort.
				if ( ! headers_sent() ) {
					@header( 'Connection: close' );
					@header( 'Content-Encoding: none' );
				}
				while ( ob_get_level() > 0 ) {
					@ob_end_flush();
				}
				@flush();
			}

			if ( function_exists( 'set_time_limit' ) ) {
				@set_time_limit( 0 );
			}
			@ignore_user_abort( true );
			if ( function_exists( 'wp_raise_memory_limit' ) ) {
				wp_raise_memory_limit( 'admin' );
			}

			// Release the session lock so polling status requests can proceed
			// while this worker is still running (PHP-FPM serializes requests
			// sharing the same session file otherwise).
			if ( function_exists( 'session_id' ) && session_id() ) {
				@session_write_close();
			}

			// Re-fetch the job — by the time shutdown fires, the loopback
			// worker may already have claimed it. The worker's own status
			// guard prevents double-processing, but skip the call entirely
			// to avoid the wasted DB round-trip.
			$job = \Antimanual\GenerationJobs::get( $job_id );
			if ( ! $job || in_array( (string) ( $job['status'] ?? '' ), array( 'processing', 'completed' ), true ) ) {
				return;
			}

			try {
				$worker = new \Antimanual\GenerationJobWorker();
				$worker->process_job( $job_id );
			} catch ( \Throwable $e ) {
				error_log( '[Antimanual] Inline worker for job ' . $job_id . ' failed: ' . $e->getMessage() );
			}
		}, 1 );
	}

	/**
	 * Fire a non-blocking loopback request to the run-worker endpoint so the
	 * job executes immediately in a separate process. SSL verification is
	 * disabled because local environments (LocalWP, MAMP, Laravel Valet, etc.)
	 * commonly use self-signed certificates which would otherwise fail.
	 *
	 * @param string $job_id
	 * @param string $token
	 * @return void
	 */
	private function dispatch_worker_http( string $job_id, string $token ): void {
		$url = rest_url( 'antimanual/v1/generate-post/run-worker' );

		wp_remote_post( $url, array(
			'timeout'   => 0.5,
			'blocking'  => false,
			'sslverify' => false,
			'cookies'   => array(),
			'body'      => array(
				'job_id' => $job_id,
				'token'  => $token,
			),
		) );
	}

	/**
	 * Internal worker endpoint. Runs the generation job synchronously in a
	 * dedicated PHP process. Validates a per-job dispatch token rather than
	 * the current user, because the loopback request comes from the server
	 * itself and does not carry session cookies.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function run_worker( $request ) {
		$job_id = sanitize_text_field( (string) ( $request->get_param( 'job_id' ) ?? '' ) );
		$token  = sanitize_text_field( (string) ( $request->get_param( 'token' ) ?? '' ) );

		if ( '' === $job_id || '' === $token ) {
			return rest_ensure_response( array( 'success' => false, 'message' => 'Missing job_id or token' ) );
		}

		$job = \Antimanual\GenerationJobs::get( $job_id );
		if ( ! $job ) {
			return rest_ensure_response( array( 'success' => false, 'message' => 'Job not found' ) );
		}

		$expected = (string) ( $job['params']['__dispatch_token'] ?? '' );
		if ( '' === $expected || ! hash_equals( $expected, $token ) ) {
			return rest_ensure_response( array( 'success' => false, 'message' => 'Forbidden' ) );
		}

		// If another worker has already claimed it, don't double-process.
		if ( in_array( $job['status'] ?? '', array( 'processing', 'completed' ), true ) ) {
			return rest_ensure_response( array( 'success' => true, 'data' => array( 'already_running' => true ) ) );
		}

		// Lift PHP limits — AI generation can run for several minutes.
		if ( function_exists( 'set_time_limit' ) ) {
			@set_time_limit( 0 );
		}
		@ignore_user_abort( true );
		if ( function_exists( 'wp_raise_memory_limit' ) ) {
			wp_raise_memory_limit( 'admin' );
		}

		// Release the session lock so the polling status requests aren't blocked
		// while this worker runs (PHP-FPM serializes requests sharing a session file).
		if ( function_exists( 'session_id' ) && session_id() ) {
			@session_write_close();
		}

		// Flush the response so the loopback dispatcher returns immediately.
		if ( function_exists( 'fastcgi_finish_request' ) ) {
			echo wp_json_encode( array( 'success' => true ) );
			fastcgi_finish_request();
		}

		$worker = new \Antimanual\GenerationJobWorker();
		$worker->process_job( $job_id );

		return rest_ensure_response( array( 'success' => true ) );
	}

	/**
	 * Get job status and result for a queued generation job.
	 *
	 * @param \WP_REST_Request $request
	 * @return \WP_REST_Response
	 */
	public function get_job_status( $request ) {
		$job_id = sanitize_text_field( (string) ( $request->get_param( 'job_id' ) ?? '' ) );
		if ( empty( $job_id ) ) {
			return rest_ensure_response( array( 'success' => false, 'message' => 'Invalid job_id' ) );
		}

		$job = \Antimanual\GenerationJobs::get( $job_id );
		if ( ! $job ) {
			return rest_ensure_response( array( 'success' => false, 'message' => 'Job not found' ) );
		}

		// Recovery: if the worker never picked up the job (cron failed, loopback
		// dispatch dropped, etc.), re-dispatch after a short grace period so the
		// frontend doesn't time out waiting on a job that's never going to run.
		$status     = (string) ( $job['status'] ?? '' );
		$created_ts = ! empty( $job['created_at'] ) ? strtotime( (string) $job['created_at'] ) : 0;
		$started_ts = ! empty( $job['started_at'] ) ? strtotime( (string) $job['started_at'] ) : 0;
		$now        = time();

		if ( 'pending' === $status && $created_ts > 0 && ( $now - $created_ts ) > 10 ) {
			$throttle_key = 'atml_dispatch_' . md5( $job_id );
			if ( false === get_transient( $throttle_key ) ) {
				$token = (string) ( $job['params']['__dispatch_token'] ?? '' );
				if ( '' !== $token ) {
					set_transient( $throttle_key, 1, 30 );
					$this->dispatch_worker_http( $job_id, $token );
				}
			}
		}

		// If a job has been "processing" for longer than the worker could possibly
		// take (AI calls have a 360s timeout each, retry cap ~20min total), the
		// worker process most likely died mid-run. Mark it failed so the frontend
		// surfaces a real error instead of polling forever.
		if ( 'processing' === $status && $started_ts > 0 && ( $now - $started_ts ) > 1200 ) {
			\Antimanual\GenerationJobs::update( $job_id, array(
				'status'      => 'failed',
				'last_error'  => __( 'Generation worker stopped responding. Please try again.', 'antimanual' ),
				'executed_at' => current_time( 'mysql' ),
			) );
			$job = \Antimanual\GenerationJobs::get( $job_id ) ?: $job;
		}

		return rest_ensure_response( array(
			'success' => true,
			'data'    => array(
				'id'         => $job['id'],
				'status'     => $job['status'],
				'created_at' => $job['created_at'] ?? null,
				'executed_at'=> $job['executed_at'] ?? null,
				'last_error' => $job['last_error'] ?? null,
				'result'     => $job['result'] ?? null,
			),
		) );
	}

	/**
	 * Resolve focus keyword from request payload.
	 *
	 * Supports both `focus_keyword` (string) and `focus_keywords` (array/JSON string).
	 *
	 * @param \WP_REST_Request $request The REST request.
	 * @return string
	 */
	private function resolve_focus_keyword( $request ): string {
		$focus_keyword = sanitize_text_field( (string) ( $request->get_param( 'focus_keyword' ) ?? '' ) );
		if ( ! empty( $focus_keyword ) ) {
			return $focus_keyword;
		}

		$focus_keywords = $request->get_param( 'focus_keywords' );

		if ( is_string( $focus_keywords ) ) {
			$decoded = json_decode( $focus_keywords, true );
			if ( is_array( $decoded ) ) {
				$focus_keywords = $decoded;
			}
		}

		if ( ! is_array( $focus_keywords ) ) {
			return '';
		}

		foreach ( $focus_keywords as $keyword ) {
			if ( ! is_scalar( $keyword ) ) {
				continue;
			}

			$cleaned = sanitize_text_field( (string) $keyword );
			if ( '' !== $cleaned ) {
				return $cleaned;
			}
		}

		return '';
	}

	/**
	 * Collect uploaded files as media attachments for AI context.
	 *
	 * @param array $files Raw files array from $_FILES['files'].
	 * @return int[] Attachment IDs.
	 */
	private function collect_uploaded_attachments( array $files ): array {
		if ( ! atml_is_public_site() ) {
			return array();
		}

		$names     = $files['name'] ?? array();
		$types     = $files['type'] ?? array();
		$tmp_names = $files['tmp_name'] ?? array();
		$errors    = $files['error'] ?? array();
		$sizes     = $files['size'] ?? array();

		if ( ! is_array( $tmp_names ) ) {
			$names     = array( $names );
			$types     = array( $types );
			$tmp_names = array( $tmp_names );
			$errors    = array( $errors );
			$sizes     = array( $sizes );
		}

		if ( ! function_exists( 'wp_handle_upload' ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
		}

		$attachments = array();

		foreach ( $tmp_names as $i => $tmp_name ) {
			$tmp_name = is_string( $tmp_name ) ? $tmp_name : '';
			$error    = intval( $errors[ $i ] ?? UPLOAD_ERR_NO_FILE );

			if ( empty( $tmp_name ) || UPLOAD_ERR_OK !== $error || ! is_uploaded_file( $tmp_name ) ) {
				continue;
			}

			$name = sanitize_file_name( (string) ( $names[ $i ] ?? ( 'upload-' . $i . '.pdf' ) ) );
			$ext  = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );

			$allowed_extensions = array( 'pdf', 'txt', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'csv', 'xls', 'xlsx' );

			if ( ! in_array( $ext, $allowed_extensions, true ) ) {
				continue;
			}

			$file_array = array(
				'name'     => $name,
				'type'     => sanitize_text_field( (string) ( $types[ $i ] ?? '' ) ),
				'tmp_name' => $tmp_name,
				'error'    => $error,
				'size'     => intval( $sizes[ $i ] ?? 0 ),
			);

			$filetype     = wp_check_filetype( $file_array['name'] );
			$allowed_exts = array( 'pdf', 'txt', 'csv', 'doc', 'docx', 'jpg', 'jpeg', 'png', 'webp' );

			if ( empty( $filetype['ext'] ) || ! in_array( strtolower( (string) $filetype['ext'] ), $allowed_exts, true ) ) {
				continue;
			}

			$moved_file = wp_handle_upload( $file_array, array( 'test_form' => false ) );
			if ( ! is_array( $moved_file ) || empty( $moved_file['file'] ) ) {
				continue;
			}

			$attachment = array(
				'post_mime_type' => sanitize_text_field( (string) ( $moved_file['type'] ?? '' ) ),
				'post_title'     => sanitize_file_name( basename( $moved_file['file'] ) ),
				'post_content'   => '',
				'post_status'    => 'inherit',
			);

			$attach_id = wp_insert_attachment( $attachment, $moved_file['file'] );
			if ( ! is_wp_error( $attach_id ) ) {
				$attachments[] = $attach_id;
			}
		}

		return $attachments;
	}

	/**
	 * List posts generated via the Generate Post feature.
	 *
	 * @since 2.3.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function list_generated_posts( $request ) {
		$page     = intval( $request->get_param( 'page' ) ?? 1 );
		$per_page = intval( $request->get_param( 'per_page' ) ?? 10 );
		$search   = sanitize_text_field( $request->get_param( 'search' ) ?? '' );
		$status   = sanitize_text_field( $request->get_param( 'status' ) ?? '' );
		$orderby  = sanitize_text_field( $request->get_param( 'orderby' ) ?? 'date' );
		$order    = sanitize_text_field( $request->get_param( 'order' ) ?? 'DESC' );

		$result = PostGenerator::get_generated_posts( $page, $per_page, $search, $status, $orderby, $order );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * Get statistics for generated posts.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function get_generated_posts_stats( $request ) {
		global $wpdb;

		$meta_key = '_atml_generated_post';

		// Total generated posts.
		$total = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT p.ID) 
                FROM {$wpdb->posts} p 
                INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 
                WHERE pm.meta_key = %s 
                AND p.post_status != 'trash'",
				$meta_key
			)
		);

		// Today's posts.
		$today_start = gmdate( 'Y-m-d 00:00:00' );
		$today       = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT p.ID) 
                FROM {$wpdb->posts} p 
                INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 
                WHERE pm.meta_key = %s 
                AND p.post_status != 'trash'
                AND p.post_date >= %s",
				$meta_key,
				$today_start
			)
		);

		// This week's posts.
		$week_start = gmdate( 'Y-m-d 00:00:00', strtotime( 'monday this week' ) );
		$this_week  = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT p.ID) 
                FROM {$wpdb->posts} p 
                INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 
                WHERE pm.meta_key = %s 
                AND p.post_status != 'trash'
                AND p.post_date >= %s",
				$meta_key,
				$week_start
			)
		);

		// This month's posts.
		$month_start = gmdate( 'Y-m-01 00:00:00' );
		$this_month  = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT p.ID) 
                FROM {$wpdb->posts} p 
                INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 
                WHERE pm.meta_key = %s 
                AND p.post_status != 'trash'
                AND p.post_date >= %s",
				$meta_key,
				$month_start
			)
		);

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'total'      => $total,
					'today'      => $today,
					'this_week'  => $this_week,
					'this_month' => $this_month,
				),
			)
		);
	}

	/**
	 * Generate a detailed prompt suggestion for post generation.
	 *
	 * @since 2.3.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function suggest_post_prompt( $request ) {
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 120 );
		}
		@ini_set( 'display_errors', '0' );

		$result = PostGenerator::generate_prompt_suggestion();

		if ( is_wp_error( $result ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				)
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'prompt' => $result,
				),
			)
		);
	}

	/**
	 * Suggest topic ideas based on the existing knowledge base.
	 *
	 * @since 2.8.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function suggest_kb_topics( $request ) {
		if ( ! \atml_is_module_enabled( 'knowledge_base' ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Knowledge Base is disabled in Configure → Modules.', 'antimanual' ),
				)
			);
		}

		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 120 );
		}
		@ini_set( 'display_errors', '0' );

		$count  = intval( $request->get_param( 'count' ) ?? 6 );
		$topic  = sanitize_text_field( (string) ( $request->get_param( 'topic' ) ?? '' ) );
		$result = PostGenerator::generate_kb_topics( $count, $topic );

		if ( is_wp_error( $result ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				)
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'topics' => $result,
				),
			)
		);
	}

	/**
	 * Enhance a rough user prompt into a detailed writing brief.
	 *
	 * @since 2.7.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function enhance_post_prompt( $request ) {
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 120 );
		}
		@ini_set( 'display_errors', '0' );

		$rough_prompt = sanitize_textarea_field( (string) ( $request->get_param( 'prompt' ) ?? '' ) );

		if ( empty( $rough_prompt ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Please provide a prompt to enhance.', 'antimanual' ),
				)
			);
		}

		$result = PostGenerator::generate_prompt_enhancement( $rough_prompt );

		if ( is_wp_error( $result ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				)
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'prompt' => $result,
				),
			)
		);
	}

	/**
	 * Research keywords for a given topic using AI.
	 *
	 * Returns a list of keyword suggestions with estimated search volume,
	 * difficulty, and search intent.
	 *
	 * @since 3.0.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function research_keywords( $request ) {
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 120 );
		}
		@ini_set( 'display_errors', '0' );

		$topic    = sanitize_text_field( (string) ( $request->get_param( 'topic' ) ?? '' ) );
		$language = sanitize_text_field( (string) ( $request->get_param( 'language' ) ?? 'English' ) );

		if ( empty( $topic ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Please provide a topic to research keywords for.', 'antimanual' ),
				)
			);
		}

		$system_prompt = 'You are an expert SEO keyword researcher. Your task is to analyze a topic and suggest highly relevant keywords for blog content.

**Output ONLY a valid JSON object. No text, comments, or explanations.**
Do NOT wrap the JSON in code fences (no ```json or similar).

The JSON object must have a single key "keywords" containing an array of keyword objects.
Each keyword object must have exactly these fields:
- "keyword": string — the keyword phrase (in the specified language)
- "volume": string — estimated monthly search volume category: "High" (10K+), "Medium" (1K-10K), or "Low" (under 1K)
- "difficulty": string — estimated ranking difficulty: "Easy", "Medium", or "Hard"
- "intent": string — search intent: "Informational", "Transactional", "Navigational", or "Commercial"

Return exactly 10 keywords. Mix primary keywords, long-tail variations, question-based queries, and related terms.
Order by relevance (most relevant first).

Example output format:
{"keywords": [{"keyword": "example keyword", "volume": "High", "difficulty": "Medium", "intent": "Informational"}]}';

		$user_prompt = sprintf(
			'Research keywords for the following topic. Return 10 keyword suggestions in %s.

Topic: "%s"

Return ONLY the JSON object with a "keywords" array, nothing else.',
			$language,
			$topic
		);

		$input = [
			[
				'role'    => 'system',
				'content' => [
					[
						'type' => 'input_text',
						'text' => $system_prompt,
					],
				],
			],
			[
				'role'    => 'user',
				'content' => [
					[
						'type' => 'input_text',
						'text' => $user_prompt,
					],
				],
			],
		];

		$response = \Antimanual\AIProvider::get_reply( $input );

		if ( ! is_string( $response ) ) {
			$error_msg = is_array( $response ) && isset( $response['error'] )
				? $response['error']
				: __( 'Failed to get response from AI provider.', 'antimanual' );

			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $error_msg,
				)
			);
		}

		// Clean and parse the JSON response.
		$response = \Antimanual\AIResponseCleaner::clean_json_response( $response );
		$decoded  = json_decode( $response, true );

		// Handle both object format {"keywords": [...]} and bare array [...].
		if ( is_array( $decoded ) && isset( $decoded['keywords'] ) && is_array( $decoded['keywords'] ) ) {
			$keywords = $decoded['keywords'];
		} elseif ( is_array( $decoded ) && isset( $decoded[0] ) ) {
			$keywords = $decoded;
		} else {
			$keywords = [];
		}

		if ( empty( $keywords ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'AI returned an invalid response. Please try again.', 'antimanual' ),
				)
			);
		}

		// Sanitize each keyword entry.
		$valid_volumes     = [ 'High', 'Medium', 'Low' ];
		$valid_difficulties = [ 'Easy', 'Medium', 'Hard' ];
		$valid_intents     = [ 'Informational', 'Transactional', 'Navigational', 'Commercial' ];

		$sanitized = [];
		foreach ( $keywords as $kw ) {
			if ( ! is_array( $kw ) || empty( $kw['keyword'] ) ) {
				continue;
			}

			$volume     = sanitize_text_field( $kw['volume'] ?? 'Low' );
			$difficulty = sanitize_text_field( $kw['difficulty'] ?? 'Medium' );
			$intent     = sanitize_text_field( $kw['intent'] ?? 'Informational' );

			$sanitized[] = [
				'keyword'    => sanitize_text_field( $kw['keyword'] ),
				'volume'     => in_array( $volume, $valid_volumes, true ) ? $volume : 'Low',
				'difficulty' => in_array( $difficulty, $valid_difficulties, true ) ? $difficulty : 'Medium',
				'intent'     => in_array( $intent, $valid_intents, true ) ? $intent : 'Informational',
			];
		}

		if ( empty( $sanitized ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'No valid keywords found. Please try a different topic.', 'antimanual' ),
				)
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array(
					'keywords' => $sanitized,
				),
			)
		);
	}

	/**
	 * Generate a content outline for preview.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function generate_outline( $request ) {
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 300 );
		}
		@ini_set( 'display_errors', '0' );

		$prompt          = sanitize_textarea_field( $request->get_param( 'prompt' ) );
		$language        = sanitize_text_field( $request->get_param( 'language' ) );
		$min_length      = intval( $request->get_param( 'min_length' ) );
		$max_length      = intval( $request->get_param( 'max_length' ) );
		$pov             = sanitize_text_field( $request->get_param( 'pov' ) ?? 'third_person' );
		$target_audience = sanitize_text_field( $request->get_param( 'target_audience' ) ?? 'general' );

		$result = PostGenerator::generate_content_outline( $prompt, $language, $min_length, $max_length, $pov, $target_audience );

		if ( is_wp_error( $result ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => $result->get_error_message(),
				)
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $result,
			)
		);
	}

	/**
	 * List saved prompt templates.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function list_templates( $request ) {
		$templates = get_option( self::TEMPLATES_OPTION, array() );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => array_values( $templates ),
			)
		);
	}

	/**
	 * Save a new prompt template.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function save_template( $request ) {
		$name   = sanitize_text_field( $request->get_param( 'name' ) );
		$prompt = sanitize_textarea_field( $request->get_param( 'prompt' ) );

		if ( empty( $name ) || empty( $prompt ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Name and prompt are required.', 'antimanual' ),
				)
			);
		}

		$templates = get_option( self::TEMPLATES_OPTION, array() );

		$id = 'template_' . wp_generate_uuid4();

		$templates[ $id ] = array(
			'id'         => $id,
			'name'       => $name,
			'prompt'     => $prompt,
			'created_at' => gmdate( 'Y-m-d H:i:s' ),
		);

		update_option( self::TEMPLATES_OPTION, $templates );

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $templates[ $id ],
			)
		);
	}

	/**
	 * Delete a prompt template.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function delete_template( $request ) {
		$id = sanitize_text_field( $request->get_param( 'id' ) );

		$templates = get_option( self::TEMPLATES_OPTION, array() );

		if ( ! isset( $templates[ $id ] ) ) {
			return rest_ensure_response(
				array(
					'success' => false,
					'message' => __( 'Template not found.', 'antimanual' ),
				)
			);
		}

		unset( $templates[ $id ] );
		update_option( self::TEMPLATES_OPTION, $templates );

		return rest_ensure_response(
			array(
				'success' => true,
			)
		);
	}

	/**
	 * List recent prompts from generated posts.
	 *
	 * @since 2.6.0
	 * @param \WP_REST_Request $request The REST request.
	 * @return \WP_REST_Response The REST response.
	 */
	public function list_recent_prompts( $request ) {
		global $wpdb;

		$limit = intval( $request->get_param( 'limit' ) ?? 5 );

		$meta_key        = '_atml_generated_post';
		$prompt_meta_key = '_atml_generation_prompt';

		$results = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT p.ID, p.post_title, pm2.meta_value as prompt, p.post_date
                FROM {$wpdb->posts} p 
                INNER JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id 
                LEFT JOIN {$wpdb->postmeta} pm2 ON p.ID = pm2.post_id AND pm2.meta_key = %s
                WHERE pm.meta_key = %s 
                AND p.post_status != 'trash'
                AND pm2.meta_value IS NOT NULL
                ORDER BY p.post_date DESC
                LIMIT %d",
				$prompt_meta_key,
				$meta_key,
				$limit
			),
			ARRAY_A
		);

		$recent = array();
		foreach ( $results as $row ) {
			$recent[] = array(
				'id'         => (int) $row['ID'],
				'prompt'     => $row['prompt'],
				'title'      => $row['post_title'],
				'created_at' => $row['post_date'],
			);
		}

		return rest_ensure_response(
			array(
				'success' => true,
				'data'    => $recent,
			)
		);
	}
}
