<?php
/**
 * Truebe documents: upload handling, gate checks, and synchronous processing
 * into seeds. Standalone port: no WP cron/transients; processing runs inline.
 *
 * Flow: handle_upload() validates and stores the PDF, then process() sends it
 * to the provider, parses Q&A seeds, saves them PENDING, fills context from the
 * PDF text, and deletes the file.
 */

if ( ! defined( 'ABSPATH' ) ) { exit; }
if ( class_exists( 'Truebe_Documents' ) ) { return; }

class Truebe_Documents {

	const MAX_BYTES        = 20971520;
	const DEFAULT_SEED_CAP = 1000;
	const PROCESS_HOOK     = 'truebe_process_document';
	const BUILD_HOOK       = 'truebe_build_seeds';
	const CONTEXT_HOOK     = 'truebe_fix_contexts';

	private static $allowed = array(
		'pdf'  => 'application/pdf',
		'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
	);

	/* ─── UPLOAD + GATE CHECKS ──────────────────────────────── */

	/* ─── STATUS ─────────────────────────────────────────────── */

	public static function set_status( $job_id, $percent, $label, $state = 'working' ) {
		if ( '' === (string) $job_id ) { return; }
		set_transient( 'truebe_job_' . $job_id, array( 'percent' => (int) $percent, 'label' => (string) $label, 'state' => (string) $state ), HOUR_IN_SECONDS );
	}

	public static function get_status( $job_id ) {
		$s = get_transient( 'truebe_job_' . $job_id );
		return is_array( $s ) ? $s : array( 'percent' => 0, 'label' => '', 'state' => 'unknown' );
	}

	/* ─── ENTRY POINT ───────────────────────────────────────── */

	/** Called by the browser right after upload. Schedules Job 1. */
	public static function run_job( $job_id ) {
		$job_id = (string) $job_id;
		if ( '' === $job_id ) { return; }
		$path = get_transient( 'truebe_jobfile_' . $job_id );
		if ( empty( $path ) ) {
			self::set_status( $job_id, 100, __( 'That upload could not be found.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}
		delete_transient( 'truebe_jobfile_' . $job_id );
		set_transient( 'truebe_jobpath_' . $job_id, $path, 2 * HOUR_IN_SECONDS );
		self::set_status( $job_id, 10, __( 'Queued', 'truebe-fact-based-ai-chatbot' ) );
		wp_schedule_single_event( time(), self::BUILD_HOOK, array( $job_id ) );
		call_user_func( array( 'Truebe_Documents', 'build_seeds' ), $job_id );
	}

	/* ─── JOB 1: BUILD SEEDS ────────────────────────────────── */

	public static function build_seeds( $job_id ) {
		$job_id = (string) $job_id;
		$path   = get_transient( 'truebe_jobpath_' . $job_id );

		if ( empty( $path ) || ! file_exists( $path ) ) {
			self::set_status( $job_id, 100, __( 'That file could not be found.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}

		if ( ! class_exists( 'Truebe_Provider' ) ) {
			self::set_status( $job_id, 100, __( 'Processing is unavailable.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}

		self::set_status( $job_id, 25, __( 'Reading your document', 'truebe-fact-based-ai-chatbot' ) );
		$bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local PDF from the plugin's own protected directory for transport to the provider; not a remote request.
		if ( false === $bytes || '' === $bytes ) {
			self::set_status( $job_id, 100, __( 'That file could not be read.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}

		self::set_status( $job_id, 50, __( 'Building answers from your document', 'truebe-fact-based-ai-chatbot' ) );
		$ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
		if ( 'docx' === $ext ) {
			$doc_text = self::extract_docx_text( $bytes );
			if ( '' === trim( $doc_text ) ) {
				self::set_status( $job_id, 100, __( 'No text could be read from this Word document.', 'truebe-fact-based-ai-chatbot' ), 'error' );
				return;
			}
			$result = Truebe_Provider::complete( self::build_prompt() . "\n\nDOCUMENT TEXT:\n" . $doc_text, null );
		} else {
			$result = Truebe_Provider::complete( self::build_prompt(), base64_encode( $bytes ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding raw PDF bytes for transport in the provider's JSON API body, not obfuscation.
		}

		if ( is_wp_error( $result ) ) {
			self::log( 'Provider call failed: ' . $result->get_error_code() . ' — ' . $result->get_error_message() );
			self::set_status( $job_id, 100, $result->get_error_message(), 'error' );
			return;
		}

		$seeds = self::parse_seeds( $result );
		if ( empty( $seeds ) ) {
			self::set_status( $job_id, 100, __( 'No answers could be found in this document.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}

		if ( self::scan_sensitive( 'docx' === $ext ? self::extract_docx_text( $bytes ) : self::extract_pdf_text( $bytes ) ) ) {
			self::set_status( $job_id, 100, __( 'Sensitive data detected. Nothing was saved.', 'truebe-fact-based-ai-chatbot' ), 'error' );
			return;
		}

		self::set_status( $job_id, 75, __( 'Saving answers', 'truebe-fact-based-ai-chatbot' ) );

		$doc_name = basename( $path );
		$seed_ids = self::insert_seeds( $seeds, $doc_name );

		set_transient( 'truebe_jobseeds_' . $job_id, $seed_ids, 2 * HOUR_IN_SECONDS );

		self::set_status( $job_id, 80, __( 'Verifying sources', 'truebe-fact-based-ai-chatbot' ) );

		call_user_func( array( 'Truebe_Documents', 'fix_contexts' ), $job_id );
	}

	/* ─── JOB 2: FIX CONTEXTS ───────────────────────────────── */

	public static function fix_contexts( $job_id ) {
		$job_id   = (string) $job_id;
		$path     = get_transient( 'truebe_jobpath_' . $job_id );
		$seed_ids = get_transient( 'truebe_jobseeds_' . $job_id );

		delete_transient( 'truebe_jobpath_' . $job_id );
		delete_transient( 'truebe_jobseeds_' . $job_id );

		if ( empty( $path ) || ! file_exists( $path ) || empty( $seed_ids ) ) {
			self::set_status( $job_id, 100, __( 'Done', 'truebe-fact-based-ai-chatbot' ), 'done' );
			return;
		}

		$bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local PDF from the plugin's own protected directory for text extraction; not a remote request.
		if ( false === $bytes || '' === $bytes ) {
			self::set_status( $job_id, 100, __( 'Done', 'truebe-fact-based-ai-chatbot' ), 'done' );
			return;
		}

		$doc_ext  = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
		$pdf_text = ( 'docx' === $doc_ext ) ? self::extract_docx_text( $bytes ) : self::extract_pdf_text( $bytes );
		if ( '' === trim( $pdf_text ) ) {
			self::set_status( $job_id, 100, __( 'Done', 'truebe-fact-based-ai-chatbot' ), 'done' );
			return;
		}

		global $wpdb;
		foreach ( $seed_ids as $seed_id => $citation ) {
			if ( '' === trim( $citation ) ) { continue; }
			$ctx = self::find_context( $pdf_text, $citation );
			if ( null === $ctx ) { continue; }
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; $wpdb->update() is the supported API and there is no object cache for this background context backfill.
			$wpdb->update(
				$wpdb->prefix . 'truebe_seeds',
				array(
					'context_before' => $ctx['context_before'],
					'context_main'   => $ctx['context_main'],
					'context_after'  => $ctx['context_after'],
				),
				array( 'id' => (int) $seed_id ),
				array( '%s', '%s', '%s' ),
				array( '%d' )
			);
		}

		self::maybe_cleanup( $path );
		self::set_status( $job_id, 100, __( 'Done', 'truebe-fact-based-ai-chatbot' ), 'done' );
	}

	/* ─── UPLOAD + GATE CHECKS ──────────────────────────────── */

	public static function handle_upload( $file, $atts ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			return self::fail( 'forbidden', __( 'You do not have permission to upload documents.', 'truebe-fact-based-ai-chatbot' ) );
		}
		if ( ! is_array( $file ) || empty( $file['tmp_name'] ) || ! isset( $file['error'] ) ) {
			return self::fail( 'no_file', __( 'No file was received. Please choose a document and try again.', 'truebe-fact-based-ai-chatbot' ) );
		}
		if ( UPLOAD_ERR_OK !== (int) $file['error'] ) {
			return self::fail( 'upload_error', __( 'The file did not finish uploading. Please try again.', 'truebe-fact-based-ai-chatbot' ) );
		}
		$gate = self::gate_attestation( $atts );
		if ( true !== $gate ) { return $gate; }
		$gate = self::gate_filetype( $file );
		if ( true !== $gate ) { return $gate; }
		$gate = self::gate_filesize( $file );
		if ( true !== $gate ) { return $gate; }
		$gate = self::gate_tier_scope();
		if ( true !== $gate ) { return $gate; }

		$stored = self::store_file( $file );
		if ( is_wp_error( $stored ) ) {
			return self::fail( 'store_failed', $stored->get_error_message() );
		}

		$job_id = wp_generate_password( 12, false );
		self::set_status( $job_id, 5, __( 'Queued', 'truebe-fact-based-ai-chatbot' ) );
		set_transient( 'truebe_jobfile_' . $job_id, $stored, HOUR_IN_SECONDS );

		return array( 'ok' => true, 'code' => '', 'message' => __( 'Document accepted.', 'truebe-fact-based-ai-chatbot' ), 'path' => $stored, 'job_id' => $job_id );
	}

	private static function gate_attestation( $atts ) {
		if ( empty( $atts['public_attested'] ) ) {
			return self::fail( 'not_attested', __( 'Please confirm this document is public, non-sensitive material before uploading.', 'truebe-fact-based-ai-chatbot' ) );
		}
		return true;
	}

	private static function gate_filetype( $file ) {
		$name = isset( $file['name'] ) ? (string) $file['name'] : '';
		$ext  = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) );
		$type = '';
		if ( function_exists( 'finfo_open' ) && ! empty( $file['tmp_name'] ) && file_exists( $file['tmp_name'] ) ) {
			$finfo = finfo_open( FILEINFO_MIME_TYPE );
			$type  = (string) finfo_file( $finfo, $file['tmp_name'] );
			finfo_close( $finfo );
		}
		// .docx is a zip on disk, so finfo reports application/zip; trust the extension for it.
		$docx_ok = ( 'docx' === $ext && in_array( $type, array( 'application/zip', self::$allowed['docx'] ), true ) );
		$pdf_ok  = ( 'pdf' === $ext && 'application/pdf' === $type );
		if ( ! $pdf_ok && ! $docx_ok ) {
			return self::fail( 'bad_type', __( 'Only PDF and Word (.docx) documents are supported.', 'truebe-fact-based-ai-chatbot' ) );
		}
		return true;
	}

	private static function gate_filesize( $file ) {
		$size = isset( $file['size'] ) ? (int) $file['size'] : 0;
		if ( $size <= 0 && file_exists( $file['tmp_name'] ) ) { $size = (int) filesize( $file['tmp_name'] ); }
		if ( $size <= 0 ) { return self::fail( 'empty_file', __( 'That file appears to be empty.', 'truebe-fact-based-ai-chatbot' ) ); }
		if ( $size > self::MAX_BYTES ) {
			/* translators: %s: human-readable maximum file size, e.g. "20 MB" */
			return self::fail( 'too_large', sprintf( __( 'That file is too large. The limit is %s.', 'truebe-fact-based-ai-chatbot' ), size_format( self::MAX_BYTES ) ) );
		}
		return true;
	}

		private static function gate_tier_scope() {
		$cap = (int) apply_filters( 'truebe_max_seeds', self::DEFAULT_SEED_CAP );
		if ( $cap <= 0 ) { return true; }
		$current = class_exists( 'Truebe_Seeds' ) ? (int) Truebe_Seeds::count() : 0;
		if ( $current >= $cap ) {
			return self::fail( 'tier_exceeded', __( 'This site has reached its plan limit. Remove some seeds or upgrade before adding more documents.', 'truebe-fact-based-ai-chatbot' ) );
		}
		return true;
	}

	private static function store_file( $file ) {

		$dir = self::protected_dir();
		if ( is_wp_error( $dir ) ) { return $dir; }

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

		// Let WordPress validate and move the upload to the standard uploads dir.
		$handled = wp_handle_upload(
			$file,
			array(
				'test_form' => false,
				'mimes'     => self::$allowed,
			)
		);

		if ( ! is_array( $handled ) || empty( $handled['file'] ) || ! empty( $handled['error'] ) ) {
			$msg = ( is_array( $handled ) && ! empty( $handled['error'] ) ) ? $handled['error'] : __( 'The document could not be saved on this server. Please try again.', 'truebe-fact-based-ai-chatbot' );
			return new WP_Error( 'move_failed', $msg );
		}

		$from = $handled['file'];

		// Relocate from the public uploads dir into the plugin's protected folder.
		$safe_name = sanitize_file_name( isset( $file['name'] ) ? $file['name'] : 'document.pdf' );
		$unique    = wp_unique_filename( $dir, $safe_name );
		$dest      = trailingslashit( $dir ) . $unique;

		global $wp_filesystem;
		if ( empty( $wp_filesystem ) ) {
			require_once ABSPATH . 'wp-admin/includes/file.php';
			WP_Filesystem();
		}

		if ( ! $wp_filesystem || ! $wp_filesystem->move( $from, $dest, true ) ) {
			// Fall back to copy+delete if move is unavailable.
			if ( $wp_filesystem && $wp_filesystem->copy( $from, $dest, true, FS_CHMOD_FILE ) ) {
				wp_delete_file( $from );
			} else {
				wp_delete_file( $from );
				return new WP_Error( 'move_failed', __( 'The document could not be saved on this server. Please try again.', 'truebe-fact-based-ai-chatbot' ) );
			}
		}

		if ( $wp_filesystem ) {
			$wp_filesystem->chmod( $dest, 0600 );
		}

		return $dest;
	}

	private static function protected_dir() {
		$uploads = wp_upload_dir();
		if ( ! empty( $uploads['error'] ) ) {
			return new WP_Error( 'no_uploads', __( 'This server has no writable uploads directory.', 'truebe-fact-based-ai-chatbot' ) );
		}
		$dir = trailingslashit( $uploads['basedir'] ) . 'fact-library';
		if ( ! wp_mkdir_p( $dir ) ) {
			return new WP_Error( 'mkdir_failed', __( 'A protected folder for documents could not be created.', 'truebe-fact-based-ai-chatbot' ) );
		}
		$htaccess = trailingslashit( $dir ) . '.htaccess';
		if ( ! file_exists( $htaccess ) ) { @file_put_contents( $htaccess, "Require all denied\nDeny from all\n" ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- writing a one-time static deny rule into the plugin's own protected directory.
		$index = trailingslashit( $dir ) . 'index.php';
		if ( ! file_exists( $index ) ) { @file_put_contents( $index, "<?php\n// Silence is golden.\n" ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged -- writing a one-time static index guard into the plugin's own protected directory.
		return $dir;
	}

	/* ─── PROMPT ─────────────────────────────────────────────── */

	private static function build_prompt() {
		return 'You are extracting verified question-and-answer seeds from the attached document. '
			. 'Use ONLY facts stated in the document. Do not infer, summarize loosely, or add outside knowledge. '
			. 'Some documents contain search results, API output, logs, or aggregated data rather than plain prose '
			. '(for example, a search returning a capped count like "20 results found" because that is the query '
			. 'limit, not the true total; or a summary line combining multiple categories into one number). Do NOT '
			. 'convert a search-result count, a capped list length, or a derived/combined statistic into a confident '
			. 'factual claim about the real world. If a number in the document is a count of search results, an API '
			. 'return limit, or an aggregate the document itself does not explicitly claim as a verified total, either '
			. 'skip that question entirely or phrase the answer to reflect what the document actually shows (for '
			. 'example "a search returned at least 20 results" rather than "there are 20"). When in doubt about '
			. 'whether a number is a real fact or an artifact of how the data was collected, do not create a seed for it. '
			. 'For each distinct fact a visitor might ask about, create one seed. '
			. 'Give each seed a clearly distinctive topic so no two seeds overlap; if two facts are close, '
			. 'make each seed name the specific thing that tells them apart (for example "attending physician" '
			. 'versus "resident physician"). '
			. 'Return a single JSON object and nothing else (no markdown, no prose) shaped exactly as: '
			. '{"seeds":[{'
			. '"label":"the canonical question a visitor would ask, phrased as a real question ending in a question mark (for example What is John Doe\'s diagnosis?), never a topic phrase or label",'
			. '"thesis":"the answer written as one complete, self-contained sentence",'
			. '"keywords":["8 to 15 distinctive words or short phrases from the question and answer"],'
			. '"phrasings":["10 to 20 different real-world ways a visitor might ask this exact question"],'
			. '"anchors":["1 to 4 short supporting facts from the document"],'
			. '"citations":[{"text":"the exact source sentence","source":"the section or heading it came from"}],'
			. '"context_main":"the complete paragraph from the document that contains citations[0].text verbatim",'
			. '"context_before":"the complete paragraph immediately before context_main, copied verbatim. Empty string if none.",'
			. '"context_after":"the complete paragraph immediately after context_main, copied verbatim. Empty string if none.",'
			. '"source_section":"the chapter or section title this falls under, if any",'
			. '"source_heading":"the nearest heading above this passage, if any",'
			. '"source_page":"the 1-based page number this is on, as an integer, or 0 if unknown",'
			. '"followups":[{"question":"a likely follow-up","thesis":"its one-sentence answer","citations":[{"text":"","source":""}]}],'
			. '"intent":"who|what|when|where|why|how",'
			. '"seed_type":"fact",'
			. '"risk_category":"general|policy|scheduling|pricing|clinical-adjacent|financial-adjacent|legal-adjacent",'
			. '"priority":"normal|high"'
			. '}]}. '
			. 'Every thesis MUST be a grammatically complete sentence that stands on its own without the question. '
			. 'The phrasings list is important: include short and long forms, synonyms, and the casual ways real '
			. 'people type questions, so the answer is found even when the wording differs.';
	}

	/* ─── CONTEXT EXTRACTION ────────────────────────────────── */

	private static function find_context( $pdf_text, $citation ) {
		if ( '' === trim( $citation ) || '' === trim( $pdf_text ) ) { return null; }

		$citation_clean = trim( $citation );
		$pos = stripos( $pdf_text, substr( $citation_clean, 0, 60 ) );
		if ( false === $pos ) { return null; }

		$start  = max( 0, $pos - 1200 );
		$length = strlen( $citation_clean ) + 2400;
		$window = substr( $pdf_text, $start, $length );

		$context_main   = trim( $window );
		$before_start   = max( 0, $start - 400 );
		$context_before = trim( substr( $pdf_text, $before_start, $start - $before_start ) );
		$after_start    = $start + $length;
		$context_after  = trim( substr( $pdf_text, $after_start, 400 ) );

		return array(
			'context_before' => $context_before,
			'context_main'   => $context_main,
			'context_after'  => $context_after,
		);
	}

	private static function extract_pdf_text( $bytes ) {
		$text = '';
		preg_match_all( '/stream\r?\n(.*?)\r?\nendstream/s', $bytes, $streams );
		foreach ( $streams[1] as $stream ) {
			$decompressed = @gzuncompress( $stream ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- probing whether a PDF stream is zlib-compressed; failure is expected and handled below.
			if ( false === $decompressed ) { $decompressed = @gzinflate( $stream ); } // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- fallback raw-deflate probe; failure is expected and handled.
			if ( false !== $decompressed ) { $stream = $decompressed; }
			preg_match_all( '/BT\s*(.*?)\s*ET/s', $stream, $bt_blocks );
			foreach ( $bt_blocks[1] as $block ) {
				preg_match_all( '/\(([^)]*)\)\s*(?:Tj|\'|")|(\[.*?\])\s*TJ/', $block, $tj );
				foreach ( $tj[1] as $chunk ) { $text .= $chunk . ' '; }
				foreach ( $tj[2] as $chunk ) {
					preg_match_all( '/\(([^)]*)\)/', $chunk, $inner );
					foreach ( $inner[1] as $piece ) { $text .= $piece; }
					$text .= ' ';
				}
			}
		}
		return $text;
	}

/**
	 * Extract plain text from a .docx (zip) byte string, dependency-free.
	 * Reads word/document.xml via ZipArchive from a temp copy of the bytes.
	 */
	private static function extract_docx_text( $bytes ) {
		if ( ! class_exists( 'ZipArchive' ) ) { return ''; }
		$tmp = wp_tempnam( 'truebe-docx' );
		if ( ! $tmp ) { return ''; }
		file_put_contents( $tmp, $bytes ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing provider-bound bytes to a private temp file for ZipArchive, removed immediately after.
		$zip  = new ZipArchive();
		$text = '';
		if ( true === $zip->open( $tmp ) ) {
			$xml = $zip->getFromName( 'word/document.xml' );
			$zip->close();
			if ( false !== $xml ) {
				$xml  = preg_replace( '/<w:p[ >]/', "\n<w:p ", $xml );
				$text = trim( wp_strip_all_tags( $xml ) );
			}
		}
		wp_delete_file( $tmp );
		return $text;
	}

	/* ─── PARSE + INSERT ────────────────────────────────────── */
	private static function parse_seeds( $text ) {
		$clean = trim( $text );
		$clean = preg_replace( '/^```(?:json)?\s*/i', '', $clean );
		$clean = preg_replace( '/\s*```$/', '', $clean );
		$data  = json_decode( $clean, true );
		if ( ! is_array( $data ) || empty( $data['seeds'] ) || ! is_array( $data['seeds'] ) ) { return array(); }
		return $data['seeds'];
	}

private static function scan_sensitive( $text ) {
		if ( '' === trim( (string) $text ) ) { return false; }
		// Social Security number.
		if ( preg_match( '/\b\d{3}-\d{2}-\d{4}\b/', $text ) ) { return true; }
		// 13-16 digit card-like number.
		if ( preg_match( '/\b(?:\d[ -]?){13,16}\b/', $text ) ) { return true; }
		// Email address.
		if ( preg_match( '/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i', $text ) ) { return true; }
		// US phone number (e.g. 317-555-1212, (317) 555-1212, 317.555.1212).
		if ( preg_match( '/(?<!\d)(?:\(\d{3}\)\s*|\d{3}[.\-\s])\d{3}[.\-\s]\d{4}(?!\d)/', $text ) ) { return true; }
		return false;
	}

	/**
	 * Insert seeds and return array of [ seed_id => citation_text ] for Job 2.
	 */
	private static function insert_seeds( $seeds, $doc_name = '' ) {
		if ( ! class_exists( 'Truebe_Seeds' ) ) {
			self::log( 'Seeds storage unavailable; nothing inserted.' );
			return array();
		}

		$seed_ids = array();
		foreach ( $seeds as $seed ) {
			if ( empty( $seed['label'] ) || empty( $seed['thesis'] ) ) { continue; }
			$id = Truebe_Seeds::insert( array(
				'label'          => $seed['label'],
				'thesis'         => $seed['thesis'],
				'intent'         => isset( $seed['intent'] ) ? $seed['intent'] : null,
				'seed_type'      => isset( $seed['seed_type'] ) ? $seed['seed_type'] : null,
				'priority'       => isset( $seed['priority'] ) ? $seed['priority'] : 'normal',
				'risk_category'  => isset( $seed['risk_category'] ) ? $seed['risk_category'] : null,
				'keywords'       => isset( $seed['keywords'] ) && is_array( $seed['keywords'] ) ? $seed['keywords'] : array(),
				'phrasings'      => isset( $seed['phrasings'] ) && is_array( $seed['phrasings'] ) ? $seed['phrasings'] : array(),
				'anchors'        => isset( $seed['anchors'] ) && is_array( $seed['anchors'] ) ? $seed['anchors'] : array(),
				'citations'      => isset( $seed['citations'] ) && is_array( $seed['citations'] ) ? $seed['citations'] : array(),
				'followups'      => isset( $seed['followups'] ) && is_array( $seed['followups'] ) ? $seed['followups'] : array(),
				'context_before' => isset( $seed['context_before'] ) ? $seed['context_before'] : null,
				'context_main'   => isset( $seed['context_main'] ) ? $seed['context_main'] : null,
				'context_after'  => isset( $seed['context_after'] ) ? $seed['context_after'] : null,
				'source_doc'     => $doc_name,
				'source_section' => isset( $seed['source_section'] ) ? $seed['source_section'] : null,
				'source_heading' => isset( $seed['source_heading'] ) ? $seed['source_heading'] : null,
				'source_page'    => isset( $seed['source_page'] ) ? (int) $seed['source_page'] : 0,
				'status'         => Truebe_Seeds::STATUS_PENDING,
			) );
			if ( $id ) {
				$cite = ! empty( $seed['citations'][0]['text'] ) ? $seed['citations'][0]['text'] : '';
				$seed_ids[ $id ] = $cite;
			}
		}
		return $seed_ids;
	}

	/* ─── HELPERS ────────────────────────────────────────────── */

	public static function process_document( $path, $doc_name = '', $job_id = '' ) {
		self::run_job( $job_id );
	}

	private static function cleanup( $path ) {
		if ( ! empty( $path ) && file_exists( $path ) ) { wp_delete_file( $path ); }
	}

	/** Delete the stored file only when the user has turned off document retention. */
	private static function maybe_cleanup( $path ) {
		if ( '1' !== (string) get_option( 'truebe_keep_documents', '1' ) ) {
			self::cleanup( $path );
		}
	}

	private static function log( $message ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( '[Truebe Documents] ' . $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- guarded by WP_DEBUG; diagnostic logging only when the site owner has debugging enabled.
		}
	}

	private static function fail( $code, $message ) {
		return array( 'ok' => false, 'code' => $code, 'message' => $message, 'path' => '' );
	}
}

add_action( Truebe_Documents::PROCESS_HOOK,  array( 'Truebe_Documents', 'process_document' ), 10, 3 );
add_action( Truebe_Documents::BUILD_HOOK,    array( 'Truebe_Documents', 'build_seeds' ) );
add_action( Truebe_Documents::CONTEXT_HOOK,  array( 'Truebe_Documents', 'fix_contexts' ) );