<?php
/**
 * REST API endpoints for SiteStaffr middleware integration.
 *
 * Provides secure REST API endpoints for the SiteStaffr middleware to fetch
 * system prompts, business settings, and other configuration data.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 * @since      0.11.88
 */

/**
 * REST API handler class.
 *
 * Registers and handles REST API endpoints for middleware integration.
 * All endpoints use HMAC signature verification for security.
 *
 * @since 0.11.88
 */
// phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Trusted plugin table names are internally derived from $wpdb->prefix and user values are placeholder-prepared.
class SiteStaffr_REST_API {

	/**
	 * Register REST API routes.
	 *
	 * Called via rest_api_init hook to register all SiteStaffr REST endpoints
	 * for middleware communication.
	 *
	 * @since 0.11.88
	 */
	public function register_routes() {
		// Business info endpoint for Realtime API middleware (simple data only)
		register_rest_route( 'sitestaffr/v1', '/business-info', array(
			'methods'             => 'GET',
			'callback'            => array( $this, 'get_business_info' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
		) );

		// System prompt endpoint for middleware (full prompt with fragments - DEPRECATED for Realtime)
		register_rest_route( 'sitestaffr/v1', '/system-prompt', array(
			'methods'             => 'GET',
			'callback'            => array( $this, 'get_system_prompt' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
			'args'                => array(
				'service_type' => array(
					'required'          => false,
					'default'           => 'webrtc',
					'sanitize_callback' => 'sanitize_text_field',
					'validate_callback' => function( $param ) {
						return in_array( $param, array( 'phone', 'webrtc' ), true );
					},
				),
				'turn_number' => array(
					'required'          => false,
					'default'           => 1,
					'sanitize_callback' => 'absint',
					'validate_callback' => function( $param ) {
						return $param >= 1;
					},
				),
				'collected_fields' => array(
					'required'          => false,
					'default'           => '',
					'sanitize_callback' => 'sanitize_text_field',
				),
				'has_caller_id' => array(
					'required'          => false,
					'default'           => false,
					'sanitize_callback' => function( $param ) {
						return filter_var( $param, FILTER_VALIDATE_BOOLEAN );
					},
				),
				'max_turns' => array(
					'required'          => false,
					'default'           => 20,
					'sanitize_callback' => 'absint',
					'validate_callback' => function( $param ) {
						return $param >= 1 && $param <= 100;
					},
				),
			),
		) );

		// Widget auth token endpoint (public - works for all visitors)
		register_rest_route( 'sitestaffr/v1', '/widget-token', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'get_widget_token' ),
			'permission_callback' => '__return_true', // Public endpoint - no authentication required
		) );

		// WebRTC Session endpoints (middleware authentication required)
		register_rest_route( 'sitestaffr/v1', '/webrtc-session', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'create_webrtc_session' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
		) );

		register_rest_route( 'sitestaffr/v1', '/webrtc-transcript', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'save_webrtc_transcript' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
		) );

		register_rest_route( 'sitestaffr/v1', '/webrtc-session/end', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'end_webrtc_session' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
		) );

		// Billing cache sync endpoint (Phase 5)
		register_rest_route( 'sitestaffr/v1', '/sync-billing-state', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'sync_billing_state' ),
			'permission_callback' => array( $this, 'verify_billing_request_permission' ),
		) );

		// Billing cache diagnostics endpoint (Phase 5 validation)
		register_rest_route( 'sitestaffr/v1', '/billing-cache-state', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'get_billing_cache_state' ),
			'permission_callback' => array( $this, 'verify_billing_request_permission' ),
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge-search', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'knowledge_search' ),
			'permission_callback' => array( $this, 'validate_middleware_request' ),
		) );

		// Admin-facing knowledge sync endpoints (nonce-protected, admin only).
		// Uses REST API instead of admin-ajax.php to bypass hosting WAF blocks.
		register_rest_route( 'sitestaffr/v1', '/knowledge/prepare', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'knowledge_prepare_sync' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge/embed-chunk', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'knowledge_embed_chunk' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge/embed-batch', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'knowledge_embed_batch' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge/chunks', array(
			'methods'             => 'GET',
			'callback'            => array( $this, 'knowledge_get_chunks' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge/unsync', array(
			'methods'             => 'POST',
			'callback'            => array( $this, 'knowledge_unsync' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );

		register_rest_route( 'sitestaffr/v1', '/knowledge/auto-sync-candidates', array(
			'methods'             => 'GET',
			'callback'            => array( $this, 'knowledge_auto_sync_candidates' ),
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		) );
	}

	/**
	 * Permission callback for billing routes that require signed requests.
	 *
	 * @since 0.14.266
	 * @param WP_REST_Request $request Request instance.
	 * @return true|WP_Error
	 */
	public function verify_billing_request_permission( $request ) {
		return $this->verify_billing_signed_request( $request );
	}

	/**
	 * Sync billing cache from middleware push.
	 *
	 * Uses dedicated signature headers:
	 * - X-SiteStaffr-Timestamp
	 * - X-SiteStaffr-Signature = HMAC_SHA256_HEX( "{timestamp}.{raw_body}" )
	 *
	 * @since 0.14.10
	 * @param WP_REST_Request $request Request instance.
	 * @return WP_REST_Response|WP_Error
	 */
	public function sync_billing_state( $request ) {
		if ( ! class_exists( 'SiteStaffr_Billing_State' ) ) {
			require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-billing-state.php';
		}

		$payload = json_decode( $request->get_body(), true );
		if ( ! is_array( $payload ) ) {
			return new WP_Error(
				'invalid_payload',
				'Invalid JSON payload',
				array( 'status' => 400 )
			);
		}

		SiteStaffr_Billing_State::update_cache_from_payload( $payload, 'middleware_push' );

		return rest_ensure_response( array(
			'success' => true,
			'message' => 'Billing cache synced',
		) );
	}

	/**
	 * Return current WordPress billing cache for Phase 5 parity validation.
	 *
	 * Uses the same signature headers as sync endpoint.
	 *
	 * @since 0.14.10
	 * @param WP_REST_Request $request Request instance.
	 * @return WP_REST_Response|WP_Error
	 */
	public function get_billing_cache_state( $request ) {
		if ( ! class_exists( 'SiteStaffr_Billing_State' ) ) {
			require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-billing-state.php';
		}

		$cache = SiteStaffr_Billing_State::get_cache();

		return rest_ensure_response( array(
			'success'         => true,
			'account_id'      => get_option( 'sitestaffr_account_id', '' ),
			'installation_id' => get_option( 'sitestaffr_installation_id', '' ),
			'billing_cache'   => $cache,
		) );
	}

	/**
	 * Verify signed billing request headers.
	 *
	 * Header requirements:
	 * - X-SiteStaffr-Timestamp
	 * - X-SiteStaffr-Signature = HMAC_SHA256_HEX( "{timestamp}.{raw_body}" )
	 *
	 * @since 0.14.10
	 * @param WP_REST_Request $request Request instance.
	 * @return true|WP_Error
	 */
	private function verify_billing_signed_request( $request ) {
		$timestamp = $request->get_header( 'x-sitestaffr-timestamp' );
		$signature = $request->get_header( 'x-sitestaffr-signature' );
		$raw_body  = $request->get_body();

		if ( empty( $timestamp ) || empty( $signature ) ) {
			return new WP_Error(
				'missing_signature_headers',
				'Missing required signature headers',
				array( 'status' => 401 )
			);
		}

		$timestamp_int = (int) $timestamp;
		$current_time  = time();
		if ( $timestamp_int <= 0 || abs( $current_time - $timestamp_int ) > 300 ) {
			return new WP_Error(
				'invalid_timestamp',
				'Invalid or expired timestamp',
				array( 'status' => 401 )
			);
		}

		$site_api_secret   = get_option( 'sitestaffr_site_api_secret', '' );
		$global_api_secret = get_option( 'sitestaffr_api_secret', '' );
		$secret            = ! empty( $site_api_secret ) ? $site_api_secret : $global_api_secret;
		if ( empty( $secret ) ) {
			return new WP_Error(
				'missing_secret',
				'No local signing secret configured',
				array( 'status' => 500 )
			);
		}

		$expected = hash_hmac( 'sha256', $timestamp . '.' . $raw_body, $secret );
		if ( ! hash_equals( $expected, strtolower( $signature ) ) ) {
			return new WP_Error(
				'invalid_signature',
				'Invalid signature',
				array( 'status' => 401 )
			);
		}

		return true;
	}

	/**
	 * Generate fresh JWT token for widget authentication.
	 *
	 * Public endpoint that generates a fresh JWT token for WebSocket authentication.
	 * Protected with multiple security layers: nonce verification, honeypot, rate limiting,
	 * and pattern detection to prevent bot attacks.
	 *
	 * @since 0.12.14
	 * @since 0.13.55 Added nonce verification, honeypot, global rate limit, and pattern detection.
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response with token, or error.
	 */
	public function get_widget_token( $request ) {
		$ip_address = $this->get_client_ip();

		// 1. Nonce verification (FIRST check)
		$nonce = $request->get_param( 'nonce' );
		if ( ! wp_verify_nonce( $nonce, 'sitestaffr_widget_token' ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				SiteStaffr_Logger::legacy( sprintf(
					'SiteStaffr Widget Token: Nonce verification failed for IP %s',
					$ip_address
				) );
			}
			return new WP_Error(
				'invalid_nonce',
				'Security verification failed',
				array( 'status' => 403 )
			);
		}

		// 2. Honeypot check (catches bots)
		$honeypot = $request->get_param( 'email' );
		if ( ! empty( $honeypot ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				SiteStaffr_Logger::legacy( sprintf(
					'SiteStaffr Widget Token: Honeypot triggered for IP %s',
					$ip_address
				) );
			}
			return new WP_Error(
				'invalid_request',
				'Security verification failed',
				array( 'status' => 403 )
			);
		}

		// 3. Global rate limiting (distributed attack protection)
		$global_limit_key = 'sitestaffr_token_global_limit';
		$global_count = get_transient( $global_limit_key );

		if ( $global_count === false ) {
			set_transient( $global_limit_key, 1, 60 );
		} elseif ( $global_count >= 100 ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr Widget Token: Global rate limit exceeded (100/minute)' );
			return new WP_Error(
				'service_unavailable',
				'Service temporarily unavailable. Please try again later.',
				array( 'status' => 503 )
			);
		} else {
			set_transient( $global_limit_key, $global_count + 1, 60 );
		}

		// 4. Per-IP rate limiting (10 per minute)
		$ip_hash = hash( 'sha256', $ip_address );
		$rate_limit_key = 'sitestaffr_token_ip_v2_' . $ip_hash;
		$request_count = get_transient( $rate_limit_key );

		if ( $request_count === false ) {
			// First request in this 1-minute window
			set_transient( $rate_limit_key, 1, 60 );
		} elseif ( $request_count >= 10 ) {
			// Rate limit exceeded
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				SiteStaffr_Logger::legacy( sprintf(
					'SiteStaffr Widget Token: Per-IP rate limit exceeded for IP %s',
					$ip_address
				) );
			}
			return new WP_Error(
				'rate_limit_exceeded',
				'Too many token requests. Please try again in about a minute.',
				array(
					'status' => 429,
					'retry_after' => 60,
				)
			);
		} else {
			// Increment counter
			set_transient( $rate_limit_key, $request_count + 1, 60 );
		}

		// 5. Suspicious pattern detection (8+ requests in 10 seconds)
		$pattern_key = 'sitestaffr_token_pattern_v2_' . $ip_hash;
		$timestamps = get_transient( $pattern_key );

		if ( $timestamps === false ) {
			$timestamps = array();
		}

		// Remove timestamps older than 10 seconds
		$current_time = time();
		$timestamps = array_filter( $timestamps, function( $ts ) use ( $current_time ) {
			return ( $current_time - $ts ) < 10;
		} );

		// Add current timestamp
		$timestamps[] = $current_time;

		// Check if suspicious pattern detected
		if ( count( $timestamps ) >= 8 ) {
			// Block IP for 2 minutes
			set_transient( 'sitestaffr_token_blocked_v2_' . $ip_hash, true, 120 );
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr Widget Token: Suspicious pattern detected - blocking IP %s for 2 minutes',
				$ip_address
			) );
			return new WP_Error(
				'rate_limit_exceeded',
				'Too many token requests. Please try again in about a minute.',
				array(
					'status' => 429,
					'retry_after' => 120,
				)
			);
		}

		// Save timestamps
		set_transient( $pattern_key, $timestamps, 10 );

		// Check if IP is blocked
		if ( get_transient( 'sitestaffr_token_blocked_v2_' . $ip_hash ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				SiteStaffr_Logger::legacy( sprintf(
					'SiteStaffr Widget Token: Request from blocked IP %s',
					$ip_address
				) );
			}
			return new WP_Error(
				'rate_limit_exceeded',
				'Too many token requests. Please try again in about a minute.',
				array(
					'status' => 429,
					'retry_after' => 120,
				)
			);
		}

		// Load auth token class if not already loaded
		if ( ! class_exists( 'SiteStaffr_Auth_Token' ) ) {
			require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-auth-token.php';
		}

		// Generate fresh JWT token (10-minute expiry for security)
		$site_id = get_site_url();
		$user_id = get_current_user_id(); // 0 for guests
		$token = SiteStaffr_Auth_Token::generate_token( $site_id, $user_id, 600 ); // 10 minutes

		// Log token generation (only in development)
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr Widget Token: Generated token for site_id=%s, user_id=%d (guest=%s), IP=%s',
				$site_id,
				$user_id,
				$user_id === 0 ? 'yes' : 'no',
				$ip_address
			) );
		}

		return rest_ensure_response( array(
			'success' => true,
			'token' => $token,
			'site_id' => $site_id,
			'expires_in' => 600, // 10 minutes
		) );
	}

	/**
	 * Get client IP address.
	 *
	 * Checks common headers for real IP when behind proxy/CDN.
	 *
	 * @since 0.12.14
	 * @return string Client IP address.
	 */
	private function get_client_ip() {
		// Check common headers for real IP (when behind proxy/CDN)
		$headers = array(
			'HTTP_CF_CONNECTING_IP', // Cloudflare
			'HTTP_X_FORWARDED_FOR',  // Standard proxy header
			'HTTP_X_REAL_IP',        // Nginx proxy
			'REMOTE_ADDR',           // Direct connection
		);

		foreach ( $headers as $header ) {
			if ( ! empty( $_SERVER[ $header ] ) ) {
				$ip = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
				// If X-Forwarded-For contains multiple IPs, use the first one
				if ( strpos( $ip, ',' ) !== false ) {
					$ips = explode( ',', $ip );
					$ip = trim( $ips[0] );
				}
				return $ip;
			}
		}

		return '0.0.0.0'; // Fallback
	}

	/**
	 * Get simplified business information for Realtime API middleware.
	 *
	 * Returns only business data (no prompt generation) for realtime voice middleware.
	 * The middleware uses this data to build the system instructions once per session.
	 *
	 * @since 0.12.33
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response object with business info, or error.
	 */
	public function get_business_info( $request ) {
		// Get the main settings array
		$saved_settings = get_option( 'sitestaffr_settings', array() );

		// Build business info array for shared enrichment
		$business_info = array(
			'business_name'        => isset( $saved_settings['business_name'] ) ? $saved_settings['business_name'] : '',
			'business_description' => isset( $saved_settings['business_context'] ) ? $saved_settings['business_context'] : get_option( 'sitestaffr_business_description', '' ),
			'business_hours'       => isset( $saved_settings['business_hours'] ) ? $saved_settings['business_hours'] : get_option( 'sitestaffr_business_hours', '' ),
			'business_phone'       => isset( $saved_settings['business_phone'] ) ? $saved_settings['business_phone'] : get_option( 'sitestaffr_business_phone', '' ),
			'custom_instructions'  => isset( $saved_settings['custom_instructions'] ) ? $saved_settings['custom_instructions'] : '',
		);
		$enriched = SiteStaffr_Business_Enrichment::enrich_business_info( $business_info );
		$enriched = SiteStaffr_Business_Enrichment::attach_hot_chunks( $enriched, 'text' );

		$business_name = SiteStaffr_Business_Enrichment::normalize_field( $enriched['business_name'] );
		$custom_greeting = isset( $saved_settings['custom_greeting'] ) ? $saved_settings['custom_greeting'] : '';
		$custom_greeting_tone = isset( $saved_settings['custom_greeting_tone'] ) ? sanitize_key( $saved_settings['custom_greeting_tone'] ) : 'warm_neutral';

		// Get widget settings for AI voice preference
		$widget_settings = get_option( 'sitestaffr_widget_settings', array() );
		$ai_voice = isset( $widget_settings['ai_voice'] ) ? $widget_settings['ai_voice'] : 'marin';

		// Get notification email (for middleware to send summaries)
		$notification_email = ! empty( $saved_settings['notification_email'] ) ? $saved_settings['notification_email'] : get_option( 'admin_email' );

		$user_custom_instructions = SiteStaffr_Business_Enrichment::get_user_custom_instructions();

		// Build response with all business data needed by middleware
		$response = array(
			'success'               => true,
			'business_name'         => $business_name,
			'business_description'  => $enriched['business_description'],
			'business_description_available' => $enriched['business_description_available'],
			'business_hours'        => $enriched['business_hours'],
			'business_phone'        => $enriched['business_phone'],
			'business_phone_available' => $enriched['business_phone_available'],
			'custom_instructions'   => $enriched['custom_instructions'],
			'missing_description_response' => $enriched['missing_description_response'],
			'missing_business_phone_response' => $enriched['missing_business_phone_response'],
			'custom_greeting'       => $custom_greeting,
			'custom_greeting_tone'  => $custom_greeting_tone,
			'ai_voice'              => $ai_voice,
			'notification_email'    => $notification_email,
			'hot_chunks'            => isset( $enriched['hot_chunks'] ) ? $enriched['hot_chunks'] : array(),
			'custom_instructions_user' => $user_custom_instructions,
		);

		// Log in development mode
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: Business info requested - business_name=%s, ai_voice=%s',
				$business_name,
				$ai_voice
			) );
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Get business information for AI conversations.
	 *
	 * Returns FULL system prompt (with fragments if enabled) OR business data only.
	 * When fragments are enabled, WordPress builds the complete prompt and middleware uses it directly.
	 * Otherwise, returns business data for middleware to build the prompt locally.
	 *
	 * @since 0.11.89
	 * @since 0.12.20 Added full prompt generation with fragment support.
	 * @deprecated For Realtime API, use get_business_info() instead.
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response object with business info, or error.
	 */
	public function get_system_prompt( $request ) {
		// Get all conversation state parameters (validated by REST API args)
		$service_type      = $request->get_param( 'service_type' );
		$turn_number       = $request->get_param( 'turn_number' );
		$collected_fields  = $request->get_param( 'collected_fields' );
		$has_caller_id     = $request->get_param( 'has_caller_id' );
		$max_turns         = $request->get_param( 'max_turns' );

		// Get the main settings array
		$saved_settings = get_option( 'sitestaffr_settings', array() );

		// Build settings array and enrich with fallbacks
		// Note: business_context comes from either settings array OR separate option
		// because the wizard saves to both locations for backward compatibility
		$raw_info = array(
			'business_name'        => isset( $saved_settings['business_name'] ) ? $saved_settings['business_name'] : '',
			'business_description' => isset( $saved_settings['business_context'] ) ? $saved_settings['business_context'] : get_option( 'sitestaffr_business_description', '' ),
			'business_hours'       => isset( $saved_settings['business_hours'] ) ? $saved_settings['business_hours'] : get_option( 'sitestaffr_business_hours', '' ),
			'business_phone'       => isset( $saved_settings['business_phone'] ) ? $saved_settings['business_phone'] : get_option( 'sitestaffr_business_phone', '' ),
			'custom_instructions'  => isset( $saved_settings['custom_instructions'] ) ? $saved_settings['custom_instructions'] : '',
		);
		$enriched = SiteStaffr_Business_Enrichment::enrich_business_info( $raw_info );

		$custom_greeting = isset( $saved_settings['custom_greeting'] ) ? $saved_settings['custom_greeting'] : '';
		$custom_greeting_tone = isset( $saved_settings['custom_greeting_tone'] ) ? sanitize_key( $saved_settings['custom_greeting_tone'] ) : 'warm_neutral';

		// Get widget settings for AI voice preference
		$widget_settings = get_option( 'sitestaffr_widget_settings', array() );
		$ai_voice = isset( $widget_settings['ai_voice'] ) ? $widget_settings['ai_voice'] : 'marin';

		$user_custom_instructions = SiteStaffr_Business_Enrichment::get_user_custom_instructions();

		// Business info object (for backward compatibility)
		$business_info = array(
			'name'        => SiteStaffr_Business_Enrichment::normalize_field( $enriched['business_name'] ),
			'description' => $enriched['business_description'],
			'hours'       => $enriched['business_hours'],
			'phone'       => $enriched['business_phone'],
			'custom'      => $enriched['custom_instructions'],
			'business_description_available' => $enriched['business_description_available'],
			'business_phone_available'       => $enriched['business_phone_available'],
			'missing_description_response'   => $enriched['missing_description_response'],
			'missing_business_phone_response' => $enriched['missing_business_phone_response'],
			'greeting'    => $custom_greeting,
			'greeting_tone' => $custom_greeting_tone,
			'ai_voice'    => $ai_voice,
			'custom_instructions_user' => $user_custom_instructions,
		);

		// REMOVED: Prompt builder logic (WordPress.org cleanup - v0.13.35)
		// Fragment system deprecated with Realtime API - prompts now handled entirely by middleware.
		// SiteStaffr_Prompt_Builder is now a stub class that always returns false for is_fragment_system_enabled().
		// All prompt construction happens in middleware for realtime voice API.

		// Return ONLY business info (middleware builds prompt)
		return rest_ensure_response( array(
			'success'           => true,
			'service_type'      => $service_type,
			'business'          => $business_info,
			'fragments_enabled' => false,
		) );
	}

	/**
	 * Search knowledge chunks by embedding similarity.
	 *
	 * Supports hybrid search when query_text is provided.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function knowledge_search( $request ) {
		$params          = $request->get_json_params();
		$query_embedding = isset( $params['query_embedding'] ) ? $params['query_embedding'] : null;
		$query_text      = isset( $params['query_text'] ) ? $params['query_text'] : '';
		$max_results     = isset( $params['max_results'] ) ? intval( $params['max_results'] ) : 10;
		$post_id         = isset( $params['post_id'] ) ? absint( $params['post_id'] ) : null;
		if ( $post_id === 0 ) {
			$post_id = null;
		}

		if ( ! is_array( $query_embedding ) || empty( $query_embedding ) ) {
			return new \WP_REST_Response( array(
				'success' => false,
				'message' => 'query_embedding array is required',
			), 400 );
		}

		$debug_passes = null;
		if ( is_string( $query_text ) && strlen( trim( $query_text ) ) >= 2 ) {
			// Sanitize: no control chars, cap length.
			$clean_text = substr( sanitize_text_field( $query_text ), 0, 500 );
			SiteStaffr_Knowledge_Manager::$last_debug_passes = null;
			$chunks = SiteStaffr_Knowledge_Manager::search_hybrid( $query_embedding, $clean_text, $max_results, $post_id );
			$debug_passes = SiteStaffr_Knowledge_Manager::$last_debug_passes;
		} else {
			$chunks = SiteStaffr_Knowledge_Manager::search( $query_embedding, $max_results, $post_id );
		}

		$response_body = array(
			'success' => true,
			'chunks'  => $chunks,
		);
		if ( $debug_passes ) {
			$response_body['debug_passes'] = $debug_passes;
		}
		return new \WP_REST_Response( $response_body, 200 );
	}

	/**
	 * Validate middleware request using HMAC signature verification.
	 *
	 * Verifies that the request comes from the SiteStaffr middleware by validating
	 * HMAC signature. Supports both new HMAC format and legacy Bearer token for
	 * backward compatibility during migration.
	 *
	 * @since 0.11.88
	 * @since 0.13.48 Added HMAC signature verification with backward compatibility.
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return bool|WP_Error True if valid, WP_Error if validation fails.
	 */
	public function validate_middleware_request( $request ) {
		$auth_header = $request->get_header( 'authorization' );

		if ( empty( $auth_header ) ) {
			return new WP_Error(
				'missing_auth',
				'Missing Authorization header',
				array( 'status' => 401 )
			);
		}

		// Try HMAC format first: "HMAC site_id:timestamp:signature"
		// site_id is a URL (contains colons), so use greedy .+ which backtracks to match :timestamp:signature at the end.
		if ( preg_match( '/^HMAC\s+(.+):(\d+):([a-f0-9]{64})$/i', $auth_header, $matches ) ) {
			return $this->validate_hmac_signature( $request, $matches[1], $matches[2], $matches[3] );
		}

		// Legacy fallback: "Bearer SECRET_HASH" (deprecated)
		if ( preg_match( '/^Bearer\s+(.+)$/i', $auth_header, $matches ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: DEPRECATED - Legacy Bearer auth used. Update middleware to use HMAC signing.' );
			return $this->validate_legacy_bearer( $matches[1] );
		}

		return new WP_Error(
			'invalid_auth_format',
			'Invalid Authorization header format',
			array( 'status' => 401 )
		);
	}

	/**
	 * Validate HMAC signature from middleware request.
	 *
	 * Verifies HMAC signature to ensure request authenticity and integrity.
	 * Prevents replay attacks via timestamp validation and request tampering
	 * via body inclusion in HMAC calculation.
	 *
	 * @since 0.13.48
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @param string          $site_id Site URL from Authorization header.
	 * @param string          $timestamp Unix timestamp from Authorization header.
	 * @param string          $provided_signature HMAC signature from Authorization header.
	 * @return bool|WP_Error True if valid, WP_Error if validation fails.
	 */
	private function validate_hmac_signature( $request, $site_id, $timestamp, $provided_signature ) {
		// Load site registration class if not already loaded
		if ( ! class_exists( 'SiteStaffr_Site_Registration' ) ) {
			require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-site-registration.php';
		}

		// Check if site is registered
		if ( ! SiteStaffr_Site_Registration::is_registered() ) {
			return new WP_Error(
				'not_registered',
				'Site is not registered with SiteStaffr middleware',
				array( 'status' => 403 )
			);
		}

		// Validate site_id matches this site
		$expected_site_id = get_site_url();
		if ( $site_id !== $expected_site_id ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: Site ID mismatch. Expected: %s, Provided: %s',
				$expected_site_id,
				$site_id
			) );
			return new WP_Error(
				'invalid_site_id',
				'Invalid site ID',
				array( 'status' => 401 )
			);
		}

		// Validate timestamp (5-minute window, prevent replay attacks)
		$current_time = time();
		$timestamp_int = (int) $timestamp;

		if ( $timestamp_int > $current_time ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: Timestamp in the future. Current: %d, Provided: %d',
				$current_time,
				$timestamp_int
			) );
			return new WP_Error(
				'invalid_timestamp',
				'Invalid timestamp',
				array( 'status' => 401 )
			);
		}

		if ( ( $current_time - $timestamp_int ) > 300 ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: Timestamp too old. Age: %d seconds',
				$current_time - $timestamp_int
			) );
			return new WP_Error(
				'expired_timestamp',
				'Request timestamp expired',
				array( 'status' => 401 )
			);
		}

		// Get site API secret (same secret the middleware uses to sign requests).
		$site_api_secret   = get_option( 'sitestaffr_site_api_secret', '' );
		$global_api_secret = get_option( 'sitestaffr_api_secret', '' );
		$site_secret       = ! empty( $site_api_secret ) ? $site_api_secret : $global_api_secret;

		if ( empty( $site_secret ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: Site secret is empty' );
			return new WP_Error(
				'invalid_secret',
				'Site secret is not configured',
				array( 'status' => 500 )
			);
		}

		// Get request body (empty string for GET requests)
		$request_body = $request->get_body();
		if ( empty( $request_body ) ) {
			$request_body = '';
		}

		// Calculate expected HMAC signature
		// Format: HMAC-SHA256(site_id + timestamp + request_body, site_secret)
		$message_to_sign = $site_id . $timestamp . $request_body;
		$expected_signature = hash_hmac( 'sha256', $message_to_sign, $site_secret );

		// Timing-safe comparison to prevent timing attacks
		if ( ! hash_equals( $expected_signature, $provided_signature ) ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: HMAC signature mismatch. Expected: %s..., Provided: %s...',
				substr( $expected_signature, 0, 16 ),
				substr( $provided_signature, 0, 16 )
			) );
			return new WP_Error(
				'invalid_signature',
				'Invalid request signature',
				array( 'status' => 401 )
			);
		}

		// HMAC signature is valid
		return true;
	}

	/**
	 * Validate legacy Bearer token authentication.
	 *
	 * Backward compatibility for middleware that hasn't been updated to HMAC.
	 * This method will be removed in a future version.
	 *
	 * @since 0.13.48
	 * @deprecated Use HMAC signature verification instead.
	 *
	 * @param string $provided_token Bearer token from Authorization header.
	 * @return bool|WP_Error True if valid, WP_Error if validation fails.
	 */
	private function validate_legacy_bearer( $provided_token ) {
		// Load site registration class if not already loaded
		if ( ! class_exists( 'SiteStaffr_Site_Registration' ) ) {
			require_once SITESTAFFR_PLUGIN_DIR . 'includes/class-sitestaffr-site-registration.php';
		}

		// Check if site is registered
		if ( ! SiteStaffr_Site_Registration::is_registered() ) {
			return new WP_Error(
				'not_registered',
				'Site is not registered with SiteStaffr middleware',
				array( 'status' => 403 )
			);
		}

		// Get site API secret (same secret the middleware uses to sign requests).
		$site_api_secret   = get_option( 'sitestaffr_site_api_secret', '' );
		$global_api_secret = get_option( 'sitestaffr_api_secret', '' );
		$site_secret       = ! empty( $site_api_secret ) ? $site_api_secret : $global_api_secret;

		if ( empty( $site_secret ) ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: Site secret is empty' );
			return new WP_Error(
				'invalid_secret',
				'Site secret is not configured',
				array( 'status' => 500 )
			);
		}

		// Calculate expected secret hash
		$expected_hash = hash( 'sha256', $site_secret );

		// Validate hash format (64 hex characters)
		if ( ! preg_match( '/^[a-f0-9]{64}$/i', $provided_token ) ) {
			return new WP_Error(
				'invalid_hash_format',
				'Invalid secret hash format',
				array( 'status' => 401 )
			);
		}

		// Compare hashes (timing-safe comparison)
		if ( ! hash_equals( $expected_hash, $provided_token ) ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: Secret hash mismatch. Expected: %s..., Provided: %s...',
				substr( $expected_hash, 0, 16 ),
				substr( $provided_token, 0, 16 )
			) );

			return new WP_Error(
				'invalid_secret_hash',
				'Invalid secret hash',
				array( 'status' => 401 )
			);
		}

		// Secret hash is valid
		return true;
	}

	/**
	 * Create a new WebRTC session (call record).
	 *
	 * Called by middleware when a WebRTC conversation starts.
	 * Creates a call record with service_type='webrtc' and returns the call_id
	 * for subsequent transcript saves.
	 *
	 * @since 0.12.23
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response with call_id, or error.
	 */
	public function create_webrtc_session( $request ) {
		global $wpdb;
		$visitor_ip = '';

		// Prefer explicit visitor IP provided by middleware (payload/header).
		$ip_candidates = array(
			$request->get_param( 'visitor_ip' ),
			$request->get_header( 'x-sitestaffr-visitor-ip' ),
		);

		foreach ( $ip_candidates as $candidate ) {
			if ( empty( $candidate ) ) {
				continue;
			}

			$candidate = sanitize_text_field( $candidate );
			if ( false !== strpos( $candidate, ',' ) ) {
				$parts = explode( ',', $candidate );
				$candidate = trim( $parts[0] );
			}

			if ( filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
				$visitor_ip = $candidate;
				break;
			}
		}

		// Block session creation for explicitly identified banned IPs only.
		if ( ! empty( $visitor_ip ) && class_exists( 'SiteStaffr_Ban_Manager' ) && SiteStaffr_Ban_Manager::is_banned( 'ip', $visitor_ip ) ) {
			SiteStaffr_Logger::legacy( sprintf( 'SiteStaffr REST API: Blocked banned IP from creating session - ip=%s', $visitor_ip ) );
			return new WP_Error(
				'identifier_banned',
				'This visitor is banned.',
				array( 'status' => 403 )
			);
		}

		// Get and validate required parameters
		$session_id = $request->get_param( 'session_id' );
		$site_id    = $request->get_param( 'site_id' );

		if ( empty( $session_id ) ) {
			return new WP_Error(
				'missing_session_id',
				'Missing required parameter: session_id',
				array( 'status' => 400 )
			);
		}

		if ( empty( $site_id ) ) {
			return new WP_Error(
				'missing_site_id',
				'Missing required parameter: site_id',
				array( 'status' => 400 )
			);
		}

		// Sanitize inputs
		$session_id = sanitize_text_field( $session_id );
		$site_id    = esc_url_raw( $site_id );

		$calls_table = $wpdb->prefix . 'phoneease_calls';

		// Check if session already exists (prevent duplicates on retries)
		$existing_call_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$calls_table} WHERE session_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted plugin table name.
				$session_id
			)
		);

		if ( $existing_call_id ) {
			// Session already created, return existing call_id
			return rest_ensure_response( array(
				'success'    => true,
				'call_id'    => (int) $existing_call_id,
				'session_id' => $session_id,
				'note'       => 'Session already exists',
			) );
		}

		// Insert new call record
		$result = $wpdb->insert(
			$calls_table,
			array(
				'session_id'   => $session_id,
				'service_type' => 'webrtc',
				'from_number'  => null,
				'to_number'    => null,
				'visitor_ip'   => ! empty( $visitor_ip ) ? $visitor_ip : null,
				'call_status'  => 'in-progress',
				'started_at'   => time(), // Unix timestamp
				'is_billable'  => 0,
				'billable_minutes' => 0,
				'is_read'      => 0,
				'is_test_call' => 0,
				'created_at'   => current_time( 'mysql' ),
			),
			array(
				'%s', // session_id
				'%s', // service_type
				'%s', // from_number (NULL)
				'%s', // to_number (NULL)
				'%s', // visitor_ip
				'%s', // call_status
				'%d', // started_at
				'%d', // is_billable
				'%d', // billable_minutes
				'%d', // is_read
				'%d', // is_test_call
				'%s', // created_at
			)
		);

		if ( $result === false ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: Failed to create WebRTC session - ' . $wpdb->last_error );
			return new WP_Error(
				'database_error',
				'Failed to create WebRTC session',
				array( 'status' => 500 )
			);
		}

		$call_id = $wpdb->insert_id;

		SiteStaffr_Logger::legacy( sprintf(
			'SiteStaffr REST API: WebRTC session created - session_id=%s, call_id=%d, site_id=%s',
			$session_id,
			$call_id,
			$site_id
		) );

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

	/**
	 * Save a transcript turn for a WebRTC session.
	 *
	 * Called by middleware for each conversation turn (can be called multiple times).
	 * Stores transcript in sitestaffr_transcripts table linked to the call_id.
	 *
	 * @since 0.12.23
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response with transcript_id, or error.
	 */
	public function save_webrtc_transcript( $request ) {
		global $wpdb;

		// Get and validate required parameters
		$session_id = $request->get_param( 'session_id' );
		$speaker    = $request->get_param( 'speaker' );
		$message    = $request->get_param( 'message' );
		$timestamp  = $request->get_param( 'timestamp' );

		// Validate required fields
		if ( empty( $session_id ) ) {
			return new WP_Error(
				'missing_session_id',
				'Missing required parameter: session_id',
				array( 'status' => 400 )
			);
		}

		if ( empty( $speaker ) ) {
			return new WP_Error(
				'missing_speaker',
				'Missing required parameter: speaker',
				array( 'status' => 400 )
			);
		}

		if ( empty( $message ) ) {
			return new WP_Error(
				'missing_message',
				'Missing required parameter: message',
				array( 'status' => 400 )
			);
		}

		// Sanitize inputs
		$session_id = sanitize_text_field( $session_id );
		$speaker    = sanitize_text_field( $speaker );
		$message    = sanitize_textarea_field( $message );

		// Validate speaker value
		if ( ! in_array( $speaker, array( 'caller', 'ai' ), true ) ) {
			return new WP_Error(
				'invalid_speaker',
				'Invalid speaker value. Must be "caller" or "ai"',
				array( 'status' => 400 )
			);
		}

		// Look up call_id from session_id
		$calls_table = $wpdb->prefix . 'phoneease_calls';
		$call_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$calls_table} WHERE session_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted plugin table name.
				$session_id
			)
		);

		if ( ! $call_id ) {
			return new WP_Error(
				'session_not_found',
				'WebRTC session not found. Create session first.',
				array( 'status' => 404 )
			);
		}

		// Parse timestamp (ISO 8601 format)
		$mysql_timestamp = current_time( 'mysql' );
		if ( ! empty( $timestamp ) ) {
			$timestamp = sanitize_text_field( $timestamp );
			// Convert ISO 8601 to MySQL format
			$dt = date_create( $timestamp );
			if ( $dt !== false ) {
				$mysql_timestamp = $dt->format( 'Y-m-d H:i:s' );
			}
		}

		// Insert transcript record
		$transcripts_table = $wpdb->prefix . 'phoneease_transcripts';
		$result = $wpdb->insert(
			$transcripts_table,
			array(
				'call_id'          => $call_id,
				'speaker'          => $speaker,
				'message_text'     => $message,
				'confidence_score' => null,
				'timestamp'        => $mysql_timestamp,
				'created_at'       => current_time( 'mysql' ),
			),
			array(
				'%d', // call_id
				'%s', // speaker
				'%s', // message_text
				'%f', // confidence_score
				'%s', // timestamp
				'%s', // created_at
			)
		);

		if ( $result === false ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: Failed to save WebRTC transcript - ' . $wpdb->last_error );
			return new WP_Error(
				'database_error',
				'Failed to save transcript',
				array( 'status' => 500 )
			);
		}

		$transcript_id = $wpdb->insert_id;

		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			SiteStaffr_Logger::legacy( sprintf(
				'SiteStaffr REST API: WebRTC transcript saved - session_id=%s, call_id=%d, speaker=%s, transcript_id=%d',
				$session_id,
				$call_id,
				$speaker,
				$transcript_id
			) );
		}

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

	/**
	 * End a WebRTC session with summary.
	 *
	 * Called by middleware when the conversation ends.
	 * Updates call status to 'completed' and saves AI summary/followup.
	 *
	 * @since 0.12.23
	 *
	 * @param WP_REST_Request $request REST API request object.
	 * @return WP_REST_Response|WP_Error Response with call_id, or error.
	 */
	public function end_webrtc_session( $request ) {
		global $wpdb;

		// Get and validate required parameters
		$session_id = $request->get_param( 'session_id' );
		$ai_summary = $request->get_param( 'ai_summary' );
		$ai_followup = $request->get_param( 'ai_followup' );
		$duration = $request->get_param( 'duration' );

		if ( empty( $session_id ) ) {
			return new WP_Error(
				'missing_session_id',
				'Missing required parameter: session_id',
				array( 'status' => 400 )
			);
		}

		// Sanitize inputs
		$session_id  = sanitize_text_field( $session_id );
		$ai_summary  = ! empty( $ai_summary ) ? sanitize_textarea_field( $ai_summary ) : '';
		$ai_followup = ! empty( $ai_followup ) ? sanitize_textarea_field( $ai_followup ) : '';
		$duration    = ! empty( $duration ) ? absint( $duration ) : 0;
		$is_billable = ( $duration >= 30 ) ? 1 : 0;
		$billable_minutes = ( 1 === $is_billable ) ? max( 1, (int) round( $duration / 60 ) ) : 0;

		// Look up call_id from session_id
		$calls_table = $wpdb->prefix . 'phoneease_calls';
		$call_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$calls_table} WHERE session_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- trusted plugin table name.
				$session_id
			)
		);

		if ( ! $call_id ) {
			return new WP_Error(
				'session_not_found',
				'WebRTC session not found',
				array( 'status' => 404 )
			);
		}

		// Update call record
		$result = $wpdb->update(
			$calls_table,
			array(
				'call_status'   => 'completed',
				'call_duration' => $duration,
				'is_billable'   => $is_billable,
				'billable_minutes' => $billable_minutes,
				'ai_summary'    => $ai_summary,
				'ai_followup'   => $ai_followup,
				'ended_at'      => current_time( 'mysql' ),
			),
			array( 'id' => $call_id ),
			array(
				'%s', // call_status
				'%d', // call_duration
				'%d', // is_billable
				'%d', // billable_minutes
				'%s', // ai_summary
				'%s', // ai_followup
				'%s', // ended_at
			),
			array( '%d' ) // WHERE id
		);

		if ( $result === false ) {
			SiteStaffr_Logger::legacy( 'SiteStaffr REST API: Failed to end WebRTC session - ' . $wpdb->last_error );
			return new WP_Error(
				'database_error',
				'Failed to update session',
				array( 'status' => 500 )
			);
		}

		// Legacy hardening: normalize NULL read-state rows to unread so
		// completed calls reliably appear in Dashboard "New Conversations".
		$wpdb->query(
			$wpdb->prepare(
				'UPDATE ' . $calls_table . '
				 SET is_read = 0, reviewed_at = NULL
				 WHERE id = %d AND is_read IS NULL',
				$call_id
			)
		);

		SiteStaffr_Logger::legacy( sprintf(
			'SiteStaffr REST API: WebRTC session ended - session_id=%s, call_id=%d, duration=%d seconds, is_billable=%d, billable_minutes=%d',
			$session_id,
			$call_id,
			$duration,
			$is_billable,
			$billable_minutes
		) );

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

	/**
	 * Phase 1: Extract text, chunk it, return chunks for embedding.
	 * Uses raw DB query + native PHP strip_tags() to avoid WordPress hooks
	 * and heavy regex that trigger hosting WAF 503 errors.
	 *
	 * @since 1.7.8
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function knowledge_prepare_sync( $request ) {
		global $wpdb;

		$post_id = absint( $request->get_param( 'post_id' ) );
		$debug   = absint( $request->get_param( 'debug_level' ) );

		// Debug level 0: static response — tests REST API routing only.
		if ( 0 === $debug && $request->has_param( 'debug_level' ) ) {
			return new \WP_REST_Response( array(
				'success' => true,
				'debug'   => 'level_0_static',
				'chunks'  => array( array( 'chunk_index' => 0, 'text' => 'Debug test chunk' ) ),
				'total'   => 1,
			), 200 );
		}

		if ( $post_id <= 0 ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Invalid post ID.' ), 400 );
		}

		// Query WITHOUT post_content first — avoids loading huge LONGTEXT into PHP.
		$meta = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT post_title, post_type, post_status, LENGTH(post_content) AS content_bytes FROM {$wpdb->posts} WHERE ID = %d",
				$post_id
			)
		);

		if ( ! $meta || 'publish' !== $meta->post_status ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Post not found or not published.' ), 200 );
		}

		// Debug level 1: metadata only — tests DB query without loading content.
		if ( 1 === $debug ) {
			return new \WP_REST_Response( array(
				'success'       => true,
				'debug'         => 'level_1_meta',
				'post_id'       => $post_id,
				'content_bytes' => intval( $meta->content_bytes ),
			), 200 );
		}

		$text = '';

		// Source 1: post_content from DB (handles Gutenberg, page builders, shortcodes).
		if ( intval( $meta->content_bytes ) > 0 ) {
			$raw_content = $wpdb->get_var(
				$wpdb->prepare(
					"SELECT post_content FROM {$wpdb->posts} WHERE ID = %d",
					$post_id
				)
			);

			// Try WordPress content filters (expands shortcodes, page builders).
			$rendered = apply_filters( 'the_content', $raw_content ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- applying WordPress core filter to expand shortcodes.
			$text     = wp_strip_all_tags( $rendered );
			$text     = preg_replace( '/\s+/', ' ', $text );
			$text     = trim( $text );

			// Fallback: raw strip if apply_filters yielded nothing.
			if ( strlen( $text ) < 10 ) {
				$text = wp_strip_all_tags( $raw_content );
				$text = preg_replace( '/\s+/', ' ', $text );
				$text = trim( $text );
			}
		}

		// Debug level 2: post_content extraction result.
		if ( 2 === $debug ) {
			return new \WP_REST_Response( array(
				'success'        => true,
				'debug'          => 'level_2_db_text',
				'post_id'        => $post_id,
				'content_bytes'  => intval( $meta->content_bytes ),
				'text_length'    => strlen( $text ),
			), 200 );
		}

		// Source 2: Fetch rendered page if post_content yielded nothing.
		// Handles theme-template content not stored in the database.
		$fetch_debug = array();
		if ( strlen( $text ) < 10 ) {
			$url      = get_permalink( $post_id );
			$response = wp_remote_get( $url, array( 'timeout' => 15, 'sslverify' => false ) );

			if ( is_wp_error( $response ) ) {
				$fetch_debug['error'] = $response->get_error_message();
			} elseif ( 200 !== wp_remote_retrieve_response_code( $response ) ) {
				$fetch_debug['http_status'] = wp_remote_retrieve_response_code( $response );
			} else {
				$html = wp_remote_retrieve_body( $response );
				$fetch_debug['html_length'] = strlen( $html );
				$fetch_debug['url']         = $url;

				$text = $this->extract_text_from_html( $html );
				$fetch_debug['extracted_length'] = strlen( $text );

				// If DOMDocument extraction failed, fall back to simple regex.
				if ( strlen( $text ) < 10 ) {
					$fallback = preg_replace( '/<(script|style|svg|iframe|noscript)[^>]*>.*?<\/\1>/is', '', $html );
					$fallback = preg_replace( '/<[^>]+class=["\'][^"\']*\bsitestaffr[^"\']*["\'][^>]*>.*?<\/[a-z]+>/is', '', $fallback );
					$fallback = wp_strip_all_tags( $fallback );
					$fallback = preg_replace( '/\s+/', ' ', $fallback );
					$text     = trim( $fallback );
					$fetch_debug['regex_fallback_length'] = strlen( $text );
				}
			}
		}

		// Debug level 3: final text extraction result with fetch diagnostics.
		if ( 3 === $debug ) {
			return new \WP_REST_Response( array(
				'success'     => true,
				'debug'       => 'level_3_extracted',
				'post_id'     => $post_id,
				'text_length' => strlen( $text ),
				'source'      => intval( $meta->content_bytes ) > 0 && strlen( $text ) >= 10 ? 'post_content' : 'rendered_page',
				'fetch_debug' => $fetch_debug,
			), 200 );
		}

		if ( strlen( $text ) < 10 ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'No content to index.' ), 200 );
		}

		// Use heading-aware chunker (h2/h3 splitting, html_entity_decode, section prefixes).
		$chunks = SiteStaffr_Content_Chunker::process_post( $post_id );

		// Fallback: if Content_Chunker found nothing (e.g., content only available
		// via rendered page fetch), use lightweight chunking on extracted text.
		if ( empty( $chunks ) && strlen( $text ) >= 10 ) {
			$chunks = $this->chunk_text_lightweight( $text );
		}

		// Debug levels 4-6: non-destructive — report results without modifying data.
		$is_diagnostic = $request->has_param( 'debug_level' );

		if ( 4 === $debug ) {
			return new \WP_REST_Response( array(
				'success'      => true,
				'debug'        => 'level_4_chunked',
				'post_id'      => $post_id,
				'total_chunks' => count( $chunks ),
			), 200 );
		}

		if ( 5 === $debug ) {
			return new \WP_REST_Response( array(
				'success'      => true,
				'debug'        => 'level_5_simulated',
				'post_id'      => $post_id,
				'total_chunks' => count( $chunks ),
			), 200 );
		}

		if ( ! $is_diagnostic ) {
			set_transient( 'sitestaffr_sync_' . $post_id, array(
				'post_title' => $meta->post_title,
				'post_type'  => $meta->post_type,
				'chunks'     => $chunks,
			), 30 * MINUTE_IN_SECONDS );
		}

		if ( 6 === $debug ) {
			return new \WP_REST_Response( array(
				'success'      => true,
				'debug'        => 'level_6_simulated',
				'post_id'      => $post_id,
				'total_chunks' => count( $chunks ),
			), 200 );
		}

		// Diagnostic mode: return results without having modified data.
		if ( $is_diagnostic ) {
			return new \WP_REST_Response( array(
				'success'      => true,
				'post_id'      => $post_id,
				'total'        => count( $chunks ),
			), 200 );
		}

		return new \WP_REST_Response( array(
			'success'  => true,
			'post_id'  => $post_id,
			'total'    => count( $chunks ),
		), 200 );
	}

	/**
	 * Chunk text into overlapping segments using native PHP string functions.
	 * Mirrors SiteStaffr_Content_Chunker constants without loading that class.
	 *
	 * @since 1.7.10
	 * @param string $text Clean plain text.
	 * @return array Array of arrays with chunk_index and text keys.
	 */
	private function chunk_text_lightweight( $text ) {
		$target_chars  = 1600; // 400 tokens * 4 chars/token.
		$max_chars     = 2000; // 500 tokens * 4 chars/token.
		$overlap_chars = 400;  // 100 tokens * 4 chars/token.
		$text_length   = strlen( $text );

		if ( $text_length <= $max_chars ) {
			return array( array( 'chunk_index' => 0, 'text' => $text ) );
		}

		$chunks      = array();
		$position    = 0;
		$chunk_index = 0;

		while ( $position < $text_length ) {
			$end = min( $position + $target_chars, $text_length );

			// Break at sentence boundary when possible.
			if ( $end < $text_length ) {
				$search_start = max( $end - 200, $position );
				$segment      = substr( $text, $search_start, $end - $search_start );
				$last_period  = strrpos( $segment, '. ' );
				if ( false !== $last_period ) {
					$end = $search_start + $last_period + 2;
				}
			}

			$chunk_text = trim( substr( $text, $position, $end - $position ) );
			if ( ! empty( $chunk_text ) ) {
				$chunks[] = array(
					'chunk_index' => $chunk_index,
					'text'        => $chunk_text,
				);
				++$chunk_index;
			}

			// Reached end of text — all content covered.
			if ( $end >= $text_length ) {
				break;
			}

			$position = $end - $overlap_chars;
		}

		return $chunks;
	}

	/**
	 * Extract readable text from a full HTML page using DOMDocument.
	 * Strips non-content elements (nav, footer, widgets, etc.) and
	 * isolates main content area. Works with any theme or page builder.
	 *
	 * @since 1.7.22
	 * @param string $html Full page HTML.
	 * @return string Clean text content.
	 */
	private function extract_text_from_html( $html ) {
		$doc = new \DOMDocument();
		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- DOMDocument warns on malformed HTML.
		@$doc->loadHTML( mb_convert_encoding( $html, 'HTML-ENTITIES', 'UTF-8' ), LIBXML_NOERROR | LIBXML_NOWARNING );

		// Phase 1: Remove elements that never contain readable text.
		$remove_tags = array( 'script', 'style', 'svg', 'iframe', 'noscript' );
		foreach ( $remove_tags as $tag ) {
			$elements = $doc->getElementsByTagName( $tag );
			while ( $elements->length > 0 ) {
				$elements->item( 0 )->parentNode->removeChild( $elements->item( 0 ) );
			}
		}

		// Phase 2: Remove SiteStaffr-specific elements only.
		$xpath = new \DOMXPath( $doc );
		$nodes = $xpath->query( "//*[contains(@class, 'sitestaffr')]" );
		foreach ( $nodes as $node ) {
			if ( $node->parentNode ) {
				$node->parentNode->removeChild( $node );
			}
		}

		// Phase 3: Try to isolate main content area.
		$content_selectors = array(
			'//main',
			'//article',
			"//*[contains(@class, 'entry-content')]",
			"//*[@id='content']",
			"//*[contains(@class, 'site-content')]",
			"//*[contains(@class, 'page-content')]",
		);

		foreach ( $content_selectors as $selector ) {
			$nodes = $xpath->query( $selector );
			if ( $nodes->length > 0 ) {
				$text = $nodes->item( 0 )->textContent;
				$text = preg_replace( '/\s+/', ' ', $text );
				$text = trim( $text );
				if ( strlen( $text ) >= 10 ) {
					return $text;
				}
			}
		}

		// Phase 4: No content area found — strip chrome, then use body.
		$chrome_tags = array( 'nav', 'header', 'footer', 'aside' );
		foreach ( $chrome_tags as $tag ) {
			$elements = $doc->getElementsByTagName( $tag );
			while ( $elements->length > 0 ) {
				$elements->item( 0 )->parentNode->removeChild( $elements->item( 0 ) );
			}
		}

		$body = $doc->getElementsByTagName( 'body' )->item( 0 );
		if ( $body ) {
			$text = $body->textContent;
			$text = preg_replace( '/\s+/', ' ', $text );
			return trim( $text );
		}

		return '';
	}

	/**
	 * Phase 2: Embed a single chunk via middleware and save to DB.
	 * One outbound HTTP call per request — stays under WAF thresholds.
	 *
	 * @since 1.7.8
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response
	 */
	public function knowledge_embed_chunk( $request ) {
		$post_id     = absint( $request->get_param( 'post_id' ) );
		$chunk_index = absint( $request->get_param( 'chunk_index' ) );
		$is_last     = (bool) $request->get_param( 'is_last' );

		if ( $post_id <= 0 ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Missing post ID.' ), 400 );
		}

		// Read chunk text from server-side transient (set by prepare endpoint).
		$sync_data = get_transient( 'sitestaffr_sync_' . $post_id );
		if ( ! $sync_data || ! isset( $sync_data['chunks'][ $chunk_index ] ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Sync data expired. Please restart sync.' ), 410 );
		}

		$chunk_text = $sync_data['chunks'][ $chunk_index ]['text'];
		$post_title = $sync_data['post_title'];
		$post_type  = $sync_data['post_type'];

		// Send single chunk to middleware for embedding.
		$response = $this->call_middleware_embed( array(
			'chunks' => array(
				array(
					'text'        => $chunk_text,
					'post_id'     => $post_id,
					'chunk_index' => $chunk_index,
				),
			),
		) );

		if ( is_wp_error( $response ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => $response->get_error_message() ), 502 );
		}

		if ( empty( $response['embeddings'][0]['vector'] ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'No embedding returned.' ), 502 );
		}

		// Delete old chunks before inserting the first new one.
		// Moved here from prepare — prevents data loss if embedding fails mid-sync.
		if ( 0 === $chunk_index ) {
			SiteStaffr_Knowledge_Manager::delete_post_chunks( $post_id );
		}

		// Save single chunk to DB.
		$chunk_title = isset( $response['embeddings'][0]['title'] ) ? $response['embeddings'][0]['title'] : null;
		$saved = SiteStaffr_Knowledge_Manager::insert_chunk( $post_id, array(
			'post_title'  => $post_title,
			'post_type'   => $post_type,
			'chunk_index' => $chunk_index,
			'text'        => $chunk_text,
			'embedding'   => $response['embeddings'][0]['vector'],
			'chunk_title' => $chunk_title,
		) );

		if ( ! $saved ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Failed to save chunk.' ), 500 );
		}

		// Update sync timestamp and cleanup transient on last chunk.
		if ( $is_last ) {
			update_post_meta( $post_id, '_sitestaffr_knowledge_synced', current_time( 'mysql' ) );
			delete_transient( 'sitestaffr_sync_' . $post_id );
		}

		return new \WP_REST_Response( array( 'success' => true, 'chunk_index' => $chunk_index ), 200 );
	}

	/**
	 * Embed multiple chunks in a single middleware call.
	 *
	 * @since 1.13.18
	 * @param WP_REST_Request $request Request with post_id, chunk_indices[], is_last.
	 * @return WP_REST_Response
	 */
	/**
	 * Return chunk texts for a synced post.
	 *
	 * @since 1.13.19
	 */
	public function knowledge_get_chunks( $request ) {
		$post_id = $request->get_param( 'post_id' );
		if ( null === $post_id ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Missing post ID.' ), 400 );
		}
		$post_id = absint( $post_id );

		$chunks = SiteStaffr_Knowledge_Manager::get_chunks_for_post( $post_id, 200 );

		return new \WP_REST_Response( array(
			'success' => true,
			'post_id' => $post_id,
			'total'   => count( $chunks ),
			'chunks'  => $chunks,
		), 200 );
	}

	/**
	 * Delete chunks for one or more posts (unsync).
	 *
	 * @since 1.13.20
	 */
	public function knowledge_unsync( $request ) {
		$post_ids = $request->get_param( 'post_ids' );
		if ( ! is_array( $post_ids ) || empty( $post_ids ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Missing post IDs.' ), 400 );
		}

		$removed = 0;
		foreach ( $post_ids as $pid ) {
			$pid = absint( $pid );
			if ( $pid <= 0 ) {
				continue;
			}
			SiteStaffr_Knowledge_Manager::delete_post_chunks( $pid );
			++$removed;
		}

		return new \WP_REST_Response( array( 'success' => true, 'removed' => $removed ), 200 );
	}

	/**
	 * Return ranked auto-sync candidates for wizard description generation.
	 *
	 * @since 1.14.0
	 * @return WP_REST_Response
	 */
	public function knowledge_auto_sync_candidates() {
		try {
		$admin = new SiteStaffr_Admin( 'sitestaffr', SITESTAFFR_VERSION );
		if ( ! method_exists( $admin, 'get_auto_sync_candidates_for_rest' ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Not available.' ), 500 );
		}

		$candidates = $admin->get_auto_sync_candidates_for_rest();

		$remaining = null;
		try {
			if ( method_exists( $admin, 'get_remaining_description_generations' ) ) {
				$remaining = $admin->get_remaining_description_generations();
			}
		} catch ( \Throwable $e ) {
			$remaining = -1; // Signal that rate check failed but don't block.
		}

		return new \WP_REST_Response( array(
			'success'                => true,
			'candidates'             => $candidates,
			'total'                  => count( $candidates ),
			'remaining_generations'  => $remaining,
		), 200 );
		} catch ( \Throwable $e ) {
			return new \WP_REST_Response( array(
				'success' => false,
				'message' => $e->getMessage(),
				'file'    => $e->getFile(),
				'line'    => $e->getLine(),
			), 500 );
		}
	}

	public function knowledge_embed_batch( $request ) {
		$post_id       = absint( $request->get_param( 'post_id' ) );
		$chunk_indices = $request->get_param( 'chunk_indices' );
		$is_last       = (bool) $request->get_param( 'is_last' );

		if ( $post_id <= 0 || ! is_array( $chunk_indices ) || empty( $chunk_indices ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Missing post ID or chunk indices.' ), 400 );
		}

		$sync_data = get_transient( 'sitestaffr_sync_' . $post_id );
		if ( ! $sync_data ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'Sync data expired. Please restart sync.' ), 410 );
		}

		$post_title = $sync_data['post_title'];
		$post_type  = $sync_data['post_type'];

		// Build batch payload for middleware.
		$mw_chunks = array();
		foreach ( $chunk_indices as $idx ) {
			$idx = absint( $idx );
			if ( ! isset( $sync_data['chunks'][ $idx ] ) ) {
				continue;
			}
			$mw_chunks[] = array(
				'text'        => $sync_data['chunks'][ $idx ]['text'],
				'post_id'     => $post_id,
				'chunk_index' => $idx,
			);
		}

		if ( empty( $mw_chunks ) ) {
			return new \WP_REST_Response( array( 'success' => false, 'message' => 'No valid chunks found.' ), 400 );
		}

		// Text-only mode: store chunks without embeddings (used by wizard
		// auto-sync where we only need chunk text for description generation).
		$text_only = (bool) $request->get_param( 'text_only' );

		// Delete old chunks before inserting the first batch.
		$min_index = min( array_map( 'absint', $chunk_indices ) );
		if ( 0 === $min_index ) {
			SiteStaffr_Knowledge_Manager::delete_post_chunks( $post_id );
		}

		$saved_count = 0;

		if ( $text_only ) {
			// Store chunk text directly — no embedding vectors.
			foreach ( $mw_chunks as $chunk ) {
				$ci = isset( $chunk['chunk_index'] ) ? absint( $chunk['chunk_index'] ) : null;
				if ( null === $ci || ! isset( $sync_data['chunks'][ $ci ] ) ) {
					continue;
				}
				$ok = SiteStaffr_Knowledge_Manager::insert_chunk( $post_id, array(
					'post_title'  => $post_title,
					'post_type'   => $post_type,
					'chunk_index' => $ci,
					'text'        => $sync_data['chunks'][ $ci ]['text'],
					'embedding'   => null,
				) );
				if ( $ok ) {
					++$saved_count;
				}
			}
		} else {
			$response = $this->call_middleware_embed( array( 'chunks' => $mw_chunks ) );

			if ( is_wp_error( $response ) ) {
				return new \WP_REST_Response( array( 'success' => false, 'message' => $response->get_error_message() ), 502 );
			}

			if ( empty( $response['embeddings'] ) || ! is_array( $response['embeddings'] ) ) {
				return new \WP_REST_Response( array( 'success' => false, 'message' => 'No embeddings returned.' ), 502 );
			}

			// Save each embedding to DB.
			foreach ( $response['embeddings'] as $emb ) {
				if ( empty( $emb['vector'] ) ) {
					continue;
				}
				$ci = isset( $emb['chunk_index'] ) ? absint( $emb['chunk_index'] ) : null;
				if ( null === $ci || ! isset( $sync_data['chunks'][ $ci ] ) ) {
					continue;
				}
				$ok = SiteStaffr_Knowledge_Manager::insert_chunk( $post_id, array(
					'post_title'  => $post_title,
					'post_type'   => $post_type,
					'chunk_index' => $ci,
					'text'        => $sync_data['chunks'][ $ci ]['text'],
					'embedding'   => $emb['vector'],
					'chunk_title' => isset( $emb['title'] ) ? $emb['title'] : null,
				) );
				if ( $ok ) {
					++$saved_count;
				}
			}
		}

		if ( $is_last ) {
			update_post_meta( $post_id, '_sitestaffr_knowledge_synced', current_time( 'mysql' ) );
			delete_transient( 'sitestaffr_sync_' . $post_id );
		}

		return new \WP_REST_Response( array( 'success' => true, 'saved' => $saved_count ), 200 );
	}

	/**
	 * Call middleware embed endpoint with HMAC signing.
	 *
	 * @since 1.7.8
	 * @param array $payload Request body.
	 * @return array|WP_Error Response data or error.
	 */
	private function call_middleware_embed( $payload ) {
		if ( ! defined( 'SITESTAFFR_MIDDLEWARE_URL' ) || empty( SITESTAFFR_MIDDLEWARE_URL ) ) {
			return new \WP_Error( 'no_middleware', 'Middleware not configured.' );
		}

		$api_secret = get_option( 'sitestaffr_site_api_secret', '' );
		if ( empty( $api_secret ) ) {
			$api_secret = get_option( 'sitestaffr_api_secret', '' );
		}
		if ( empty( $api_secret ) ) {
			return new \WP_Error( 'no_secret', 'API secret not configured.' );
		}

		$body      = wp_json_encode( $payload );
		$timestamp = (string) time();
		$signature = base64_encode( hash_hmac( 'sha256', $timestamp . '.' . $body, $api_secret, true ) );

		$response = wp_remote_post(
			rtrim( SITESTAFFR_MIDDLEWARE_URL, '/' ) . '/api/knowledge/embed',
			array(
				'timeout' => 60,
				'headers' => array(
					'Content-Type'           => 'application/json',
					'X-SiteStaffr-Signature' => $signature,
					'X-SiteStaffr-Timestamp' => $timestamp,
					'X-SiteStaffr-Site-URL'  => esc_url_raw( get_site_url() ),
				),
				'body'    => $body,
			)
		);

		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$code = wp_remote_retrieve_response_code( $response );
		$data = json_decode( wp_remote_retrieve_body( $response ), true );

		if ( 200 !== $code || empty( $data['success'] ) ) {
			$msg = isset( $data['message'] ) ? $data['message'] : 'Embedding request failed.';
			return new \WP_Error( 'embed_failed', $msg );
		}

		return $data;
	}
}
// phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
