<?php
/**
 * AI-powered form generation for Basic Forms.
 *
 * @package BasicForms
 */

namespace BasicForms;

use WP_PowerSuite\AI\ConnectionSettings;
use WP_PowerSuite\AI\OpenAiCompatibleClient;
use WP_PowerSuite\AI\ProviderRegistry;

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

/**
 * Generates form schemas from natural-language prompts via site-wide AI connections.
 */
final class AiFormGenerator {

	private const MAX_PROMPT_LENGTH = 8000;

	private const MAX_HISTORY_MESSAGES = 20;

	private const MAX_HISTORY_CONTENT = 2000;

	/** @var list<string> */
	private const ALLOWED_TYPES = [
		'text',
		'textarea',
		'email',
		'phone',
		'number',
		'select',
		'radio',
		'checkbox',
		'file',
		'date',
		'time',
		'datetime',
		'url',
		'hidden',
		'password',
		'html',
		'separator',
		'group',
	];

	/** @var list<string> */
	private const INTENTS = [ 'create', 'modify', 'add_fields', 'replace', 'design', 'auto' ];

	/**
	 * Infer how to apply an edit-mode prompt. Edits now use a single "auto" intent
	 * so the model can change fields, settings, and design together — only a clear
	 * "rebuild from scratch" request short-circuits to a full replace.
	 *
	 * @param list<array{role: string, content: string}> $history
	 */
	public static function inferIntentFromPrompt( string $prompt, bool $has_context = true, array $history = [] ): string {
		if ( ! $has_context ) {
			return 'create';
		}

		$current = mb_strtolower( trim( $prompt ) );
		if ( self::isReplaceRequest( $current ) ) {
			return 'replace';
		}

		// Pure "add …" requests must stay append-only. Routing them through auto
		// lets weak models rewrite the whole form and silently drop existing fields.
		if ( self::isAddOnlyRequest( $current ) && ! self::isRemoveRequest( $current ) && ! self::isExistingFieldEditRequest( $current ) ) {
			return 'add_fields';
		}

		return 'auto';
	}

	public static function isReplaceRequest( string $prompt ): bool {
		return (bool) preg_match( '/\b(replace all|replace the entire|rebuild|start over|from scratch|entire form|whole form|regenerate|redo the (whole )?form|new form instead|scrap (it|this|the form))\b/u', mb_strtolower( $prompt ) );
	}

	public static function isRemoveRequest( string $prompt ): bool {
		return (bool) preg_match( '/\b(remove|delete|drop|get rid of|take out)\b/u', mb_strtolower( $prompt ) );
	}

	/**
	 * True when the user is asking to add something to the form (not restyle it).
	 */
	public static function isAddOnlyRequest( string $prompt ): bool {
		$p = mb_strtolower( trim( $prompt ) );
		if ( $p === '' ) {
			return false;
		}

		$add_verb = (bool) preg_match( '/\b(add|include|insert|append|plus)\b/u', $p )
			|| (bool) preg_match( '/\b(another|one more|few more|\d+\s+more)\s+fields?\b/u', $p )
			|| (bool) preg_match( '/\bneed(s)?\s+(a|an|another)\b/u', $p );

		if ( ! $add_verb ) {
			return false;
		}

		$design_only = (bool) preg_match( '/\b(theme|design|styling|styles?|color|background|font|css|appearance)\b/u', $p )
			&& ! (bool) preg_match( '/\bfields?\b/u', $p )
			&& ! (bool) preg_match( '/\b(phone|email|date|checkbox|radio|textarea|dropdown|select|file|number|time|url|address|company|name|message)\b/u', $p );

		return ! $design_only;
	}

	public static function isExistingFieldEditRequest( string $prompt ): bool {
		return (bool) preg_match( '/\b(rename|reorder|re-?order|make (it |them |the )?(required|optional)|change the|update the|edit the|inline|columns?|placeholder|conditional)\b/u', mb_strtolower( $prompt ) );
	}

	/**
	 * Detect an explicit "reset the design / revert styling to default" request.
	 * Kept conservative so ordinary design edits are not treated as a full reset.
	 */
	public static function isDesignResetRequest( string $prompt ): bool {
		$p = mb_strtolower( trim( $prompt ) );
		if ( $p === '' ) {
			return false;
		}

		$reset_verb   = (bool) preg_match( '/\b(reset|revert|restore)\b/u', $p );
		$clear_verb   = (bool) preg_match( '/\bclear\b/u', $p );
		$design_noun  = (bool) preg_match( '/\b(design|styling|styles?|theme|appearance|formatting)\b/u', $p );
		$default_word = (bool) preg_match( '/\bdefaults?\b/u', $p );

		if ( $reset_verb && ( $design_noun || $default_word ) ) {
			return true; // "reset design", "revert to default", "restore default styling".
		}
		if ( $clear_verb && $design_noun ) {
			return true; // "clear the styling/design".
		}
		if ( $default_word && $design_noun ) {
			return true; // "default design", "use the default theme".
		}

		return false;
	}

	/**
	 * @param array<string, mixed> $options {
	 *     @type string               $intent  create|modify|add_fields|replace|design
	 *     @type array<string, mixed> $context Optional current form snapshot.
	 *     @type string               $model   Optional model override.
	 * }
	 * @return array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design: array<string, string>, form_id: int}|\WP_Error
	 */
	public function generate( string $prompt, ?string $provider = null, array $options = [] ): array|\WP_Error {
		$prompt = trim( $prompt );
		if ( $prompt === '' ) {
			return new \WP_Error( 'empty_prompt', __( 'Describe what you want the form to do.', 'wp-powersuite' ) );
		}

		if ( mb_strlen( $prompt ) > self::MAX_PROMPT_LENGTH ) {
			return new \WP_Error(
				'prompt_too_long',
				sprintf(
					/* translators: %d: maximum character count */
					__( 'Prompt must be %d characters or fewer.', 'wp-powersuite' ),
					self::MAX_PROMPT_LENGTH
				)
			);
		}

		$intent  = $this->sanitizeIntent( (string) ( $options['intent'] ?? 'create' ) );
		$context = $this->normalizeContext( is_array( $options['context'] ?? null ) ? $options['context'] : [] );

		// Deterministic "reset the design to default" — applied without calling the
		// model so it always works on an explicit request.
		if ( 'auto' === $intent && self::isDesignResetRequest( $prompt ) ) {
			// Only the design changes; fields/title/settings are left untouched
			// (the frontend applies a part only when it is listed in "changed").
			return [
				'title'      => $context['title'],
				'fields'     => [],
				'settings'   => [],
				'design'     => DesignDefaults::get(),
				'changed'    => [ 'design' ],
				'ai_message' => __( 'Reset the form design to the default theme. Open Form Design to review the result.', 'wp-powersuite' ),
			];
		}

		$module_ai = AiSettings::get();
		$provider  = ConnectionSettings::sanitizeProvider( $provider ?? (string) ( $module_ai['provider'] ?? 'openai' ) );
		$model     = FormAiModels::sanitize( (string) ( $options['model'] ?? $module_ai['model'] ?? '' ), $provider );
		$conn      = ConnectionSettings::get();
		$api_key   = ConnectionSettings::getApiKey( $conn, $provider );

		if ( $api_key === '' ) {
			return new \WP_Error(
				'missing_api_key',
				__( 'No AI API key is configured for the selected provider. Add one in WP PowerSuite Settings → AI Connections and choose a provider in Basic Forms settings.', 'wp-powersuite' )
			);
		}

		// Streaming is only possible for the OpenAI-style chat completions endpoint and
		// when the caller supplies a delta callback (the SSE endpoint). Everything else
		// falls back to a single buffered response.
		$stream_cb  = ( isset( $options['stream_callback'] ) && is_callable( $options['stream_callback'] ) ) ? $options['stream_callback'] : null;
		$can_stream = null !== $stream_cb && in_array( ProviderRegistry::apiFamily( $provider ), [ 'openai', 'openai_compatible' ], true );

		// Streaming uses the SAME JSON-mode prompt as the buffered path so structured
		// output stays reliable. We do not stream a natural-language preamble (models
		// return it inconsistently); instead the client reveals the summary on "done".
		$system_prompt = $this->buildSystemPrompt( $intent );
		$user_prompt   = $this->buildUserPrompt( $prompt, $intent, $context );
		$history       = self::normalizeHistory( is_array( $options['history'] ?? null ) ? $options['history'] : [] );

		/**
		 * Filter AI form builder prompts before sending to the provider.
		 *
		 * @param array{system: string, user: string} $prompts
		 * @param string                              $provider
		 * @param string                              $intent
		 * @param array<string, mixed>                $context
		 */
		$prompts = apply_filters(
			'wpps_basic_forms_ai_generate_prompt',
			[
				'system' => $system_prompt,
				'user'   => $user_prompt,
			],
			$provider,
			$intent,
			$context
		);

		$tuning = $this->generationTuning( $intent );

		if ( $can_stream ) {
			$raw = $this->callProviderStreaming(
				$provider,
				$model,
				$api_key,
				(string) ( $prompts['system'] ?? $system_prompt ),
				(string) ( $prompts['user'] ?? $user_prompt ),
				$history,
				$tuning,
				$stream_cb
			);
		} else {
			$raw = $this->callProvider(
				$provider,
				$model,
				$api_key,
				(string) ( $prompts['system'] ?? $system_prompt ),
				(string) ( $prompts['user'] ?? $user_prompt ),
				$history,
				$tuning
			);
		}
		if ( is_wp_error( $raw ) ) {
			return $raw;
		}

		$payload = $this->parsePayload( $raw );

		// One corrective pass when the model returns malformed JSON or a payload
		// that carries no usable change for the requested intent.
		if ( is_wp_error( $payload ) || ! $this->payloadHasUsableChange( $payload, $intent ) ) {
			$reason  = is_wp_error( $payload )
				? __( 'The previous response was not valid JSON.', 'wp-powersuite' )
				: __( 'The previous response did not contain a usable change.', 'wp-powersuite' );
			$repaired = $this->repairPayload(
				$provider,
				$model,
				$api_key,
				(string) ( $prompts['system'] ?? $system_prompt ),
				$raw,
				$reason,
				$history,
				$tuning
			);
			if ( ! is_wp_error( $repaired ) ) {
				$payload = $repaired;
			}
		}
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		$normalized = $this->normalizePayload( $payload, $intent, $context );
		$normalized = $this->protectExistingFields( $normalized, $intent, $context, $prompt );

		/**
		 * Filter parsed AI form payload before sanitization.
		 *
		 * @param array<string, mixed> $normalized
		 * @param string               $prompt
		 * @param string               $provider
		 * @param string               $intent
		 */
		$normalized = apply_filters( 'wpps_basic_forms_ai_generate_result', $normalized, $prompt, $provider, $intent );

		return $normalized;
	}

	/**
	 * Rewrite a draft chat prompt into a clearer form-builder instruction.
	 * Returns plain text (not a form payload) so the user can review it before Send.
	 *
	 * @param array<string, mixed> $options {
	 *     @type string               $mode    create|edit
	 *     @type array<string, mixed> $context Optional current form snapshot.
	 *     @type string               $model   Optional model override.
	 * }
	 */
	public function improvePrompt( string $prompt, ?string $provider = null, array $options = [] ): string|\WP_Error {
		$prompt = trim( $prompt );
		if ( $prompt === '' ) {
			return new \WP_Error( 'empty_prompt', __( 'Type a request first, then improve it.', 'wp-powersuite' ) );
		}

		if ( mb_strlen( $prompt ) > self::MAX_PROMPT_LENGTH ) {
			return new \WP_Error(
				'prompt_too_long',
				sprintf(
					/* translators: %d: maximum character count */
					__( 'Prompt must be %d characters or fewer.', 'wp-powersuite' ),
					self::MAX_PROMPT_LENGTH
				)
			);
		}

		$module_ai = AiSettings::get();
		$provider  = ConnectionSettings::sanitizeProvider( $provider ?? (string) ( $module_ai['provider'] ?? 'openai' ) );
		$model     = FormAiModels::sanitize( (string) ( $options['model'] ?? $module_ai['model'] ?? '' ), $provider );
		$conn      = ConnectionSettings::get();
		$api_key   = ConnectionSettings::getApiKey( $conn, $provider );

		if ( $api_key === '' ) {
			return new \WP_Error(
				'missing_api_key',
				__( 'No AI API key is configured for the selected provider. Add one in WP PowerSuite Settings → AI Connections and choose a provider in Basic Forms settings.', 'wp-powersuite' )
			);
		}

		$mode    = isset( $options['mode'] ) ? sanitize_key( (string) $options['mode'] ) : 'create';
		$context = $this->normalizeContext( is_array( $options['context'] ?? null ) ? $options['context'] : [] );
		$is_edit = 'edit' === $mode || 'fields' === $mode;

		$system = 'You are a prompt editor, not a form generator.'
			. ' Rewrite the user\'s draft into one clear instruction they can send to a form builder.'
			. ' Keep their intent. Fix grammar. Name useful field labels and types in ordinary sentences.'
			. ' NEVER output JSON, code, braces, or a form schema. NEVER start with { or ```.'
			. ' Return ONLY the improved instruction as plain text. No preamble like "Here is". Under 400 characters.';

		$user = $is_edit
			? $this->protectedFieldsBlock( $context )
				. "The user is editing an existing form titled \"" . ( $context['title'] !== '' ? $context['title'] : 'Untitled' ) . "\".\n"
				. "Example: draft \"add phone\" → \"Add a required Phone Number field after Email. Do not remove existing fields.\"\n"
				. "Rewrite this draft:\n" . $prompt
			: "The user is creating a new form.\n"
				. "Example: draft \"event booking\" → \"Create an event booking form with Full Name, Email, Event Date, Number of Guests, and a comments box. Name and Email required.\"\n"
				. "Rewrite this draft:\n" . $prompt;

		$raw = $this->callProvider(
			$provider,
			$model,
			$api_key,
			$system,
			$user,
			[],
			[
				'json_mode'   => false,
				'temperature' => 0.3,
				'max_tokens'  => 400,
			]
		);
		if ( is_wp_error( $raw ) ) {
			return $raw;
		}

		$improved = $this->sanitizeImprovedPrompt( (string) $raw, $prompt, $is_edit );
		if ( $improved === '' ) {
			return new \WP_Error( 'empty_response', __( 'Could not improve that prompt. Try rephrasing it first.', 'wp-powersuite' ) );
		}

		return $improved;
	}

	/**
	 * Keep Improve Prompt as natural language even if the model returns a form schema.
	 */
	private function sanitizeImprovedPrompt( string $raw, string $original, bool $is_edit ): string {
		$improved = trim( $raw );
		$improved = preg_replace( '/^```(?:\w+)?\s*|\s*```$/', '', $improved ) ?? $improved;
		$improved = trim( $improved, " \t\n\r\0\x0B\"'" );

		$decoded = json_decode( $improved, true );
		if ( ! is_array( $decoded ) && preg_match( '/\{[\s\S]*\}/', $improved, $match ) ) {
			$decoded = json_decode( $match[0], true );
		}

		if ( is_array( $decoded ) ) {
			$from_json = $this->promptFromAccidentalSchema( $decoded, $is_edit );
			if ( $from_json !== '' ) {
				$improved = $from_json;
			}
		}

		$improved = trim( preg_replace( '/\s+/', ' ', $improved ) ?? $improved );
		if ( $improved === '' || '{' === substr( ltrim( $improved ), 0, 1 ) ) {
			return $original;
		}

		if ( mb_strlen( $improved ) > 500 ) {
			$improved = rtrim( mb_substr( $improved, 0, 500 ) );
		}

		return $improved;
	}

	/**
	 * @param array<string, mixed> $schema
	 */
	private function promptFromAccidentalSchema( array $schema, bool $is_edit ): string {
		$title  = isset( $schema['form_title'] ) ? sanitize_text_field( (string) $schema['form_title'] ) : '';
		if ( $title === '' && isset( $schema['title'] ) ) {
			$title = sanitize_text_field( (string) $schema['title'] );
		}

		$labels = [];
		$fields = isset( $schema['fields'] ) && is_array( $schema['fields'] ) ? $schema['fields'] : [];
		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			$label = isset( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : '';
			if ( $label !== '' ) {
				$labels[] = $label;
			}
			if ( count( $labels ) >= 8 ) {
				break;
			}
		}

		if ( $is_edit ) {
			if ( [] === $labels ) {
				return '';
			}
			return sprintf(
				/* translators: %s: comma-separated field labels */
				__( 'Add these fields to the form: %s. Keep every existing field.', 'wp-powersuite' ),
				implode( ', ', $labels )
			);
		}

		if ( $title !== '' && [] !== $labels ) {
			return sprintf(
				/* translators: 1: form title, 2: comma-separated field labels */
				__( 'Create a "%1$s" form with these fields: %2$s.', 'wp-powersuite' ),
				$title,
				implode( ', ', $labels )
			);
		}

		if ( [] !== $labels ) {
			return sprintf(
				/* translators: %s: comma-separated field labels */
				__( 'Create a form with these fields: %s.', 'wp-powersuite' ),
				implode( ', ', $labels )
			);
		}

		return $title !== ''
			? sprintf(
				/* translators: %s: form title */
				__( 'Create a "%s" form.', 'wp-powersuite' ),
				$title
			)
			: '';
	}

	/**
	 * @param list<array<string, mixed>> $history
	 * @return list<array{role: string, content: string}>
	 */
	public static function normalizeHistory( array $history ): array {
		$normalized = [];

		foreach ( array_slice( $history, -self::MAX_HISTORY_MESSAGES ) as $message ) {
			if ( ! is_array( $message ) ) {
				continue;
			}

			$role = isset( $message['role'] ) ? sanitize_key( (string) $message['role'] ) : '';
			if ( ! in_array( $role, [ 'user', 'assistant' ], true ) ) {
				continue;
			}

			$content = isset( $message['content'] ) ? trim( sanitize_textarea_field( (string) $message['content'] ) ) : '';
			if ( $content === '' ) {
				continue;
			}

			if ( mb_strlen( $content ) > self::MAX_HISTORY_CONTENT ) {
				$content = mb_substr( $content, 0, self::MAX_HISTORY_CONTENT );
			}

			$normalized[] = [
				'role'    => $role,
				'content' => $content,
			];
		}

		while ( [] !== $normalized && 'assistant' === $normalized[0]['role'] ) {
			array_shift( $normalized );
		}

		return self::ensureAlternatingRoles( $normalized );
	}

	/**
	 * Drop consecutive duplicate roles so provider APIs receive valid turns.
	 *
	 * @param list<array{role: string, content: string}> $history
	 * @return list<array{role: string, content: string}>
	 */
	private static function ensureAlternatingRoles( array $history ): array {
		$clean = [];

		foreach ( $history as $message ) {
			if ( [] !== $clean && $clean[ array_key_last( $clean ) ]['role'] === $message['role'] ) {
				$clean[ array_key_last( $clean ) ]['content'] .= "\n" . $message['content'];
				continue;
			}

			$clean[] = $message;
		}

		return $clean;
	}

	/**
	 * Human-readable chat summary for a successful AI response.
	 *
	 * @param array<string, mixed> $result
	 */
	public static function buildAssistantSummary( array $result, string $intent ): string {
		if ( 'auto' === $intent ) {
			$message = isset( $result['ai_message'] ) ? trim( (string) $result['ai_message'] ) : '';
			if ( $message !== '' ) {
				return $message;
			}

			$changed = isset( $result['changed'] ) && is_array( $result['changed'] ) ? $result['changed'] : [];
			if ( [] === $changed ) {
				return __( 'I could not detect a change to apply. Try rephrasing your request.', 'wp-powersuite' );
			}

			$parts = [];
			if ( in_array( 'fields', $changed, true ) ) {
				$count = count( self::fieldLabelsFromResult( $result ) );
				$parts[] = sprintf(
					/* translators: %d: number of fields */
					_n( 'the %d form field', 'the %d form fields', $count, 'wp-powersuite' ),
					$count
				);
			}
			if ( in_array( 'design', $changed, true ) ) {
				$parts[] = __( 'the design', 'wp-powersuite' );
			}
			if ( in_array( 'settings', $changed, true ) ) {
				$parts[] = __( 'form settings', 'wp-powersuite' );
			}
			if ( in_array( 'title', $changed, true ) ) {
				$parts[] = __( 'the form name', 'wp-powersuite' );
			}

			return sprintf(
				/* translators: %s: list of changed parts, e.g. "the design and form settings" */
				__( 'Updated %s on the form. Click Update Form to keep the changes, or Undo in the chat if you want to revert.', 'wp-powersuite' ),
				self::joinList( $parts )
			);
		}

		if ( 'design' === $intent ) {
			return __( 'Updated the form design. Click Update Form to keep it, or Undo in the chat to revert.', 'wp-powersuite' );
		}

		if ( 'add_fields' === $intent ) {
			$labels = self::fieldLabelsFromResult( $result );
			if ( [] !== $labels ) {
				return sprintf(
					/* translators: 1: number of fields, 2: comma-separated field labels */
					__( 'Added %1$d field(s) to the form: %2$s. Click Update Form to save, or Undo in the chat to remove them.', 'wp-powersuite' ),
					count( $labels ),
					implode( ', ', array_slice( $labels, 0, 8 ) )
				);
			}

			return __( 'Added new fields to the form. Click Update Form to save, or Undo in the chat to remove them.', 'wp-powersuite' );
		}

		$title  = isset( $result['title'] ) ? sanitize_text_field( (string) $result['title'] ) : '';
		$labels = self::fieldLabelsFromResult( $result );

		if ( in_array( $intent, [ 'create', 'replace' ], true ) ) {
			$summary = $title !== ''
				? sprintf(
					/* translators: 1: form title, 2: field count */
					__( 'Created form "%1$s" with %2$d field(s)', 'wp-powersuite' ),
					$title,
					count( $labels )
				)
				: sprintf(
					/* translators: %d: field count */
					__( 'Created a form with %d field(s)', 'wp-powersuite' ),
					count( $labels )
				);
		} else {
			$summary = $title !== ''
				? sprintf(
					/* translators: %s: form title */
					__( 'Updated form "%s"', 'wp-powersuite' ),
					$title
				)
				: __( 'Updated your form based on your request', 'wp-powersuite' );
		}

		if ( [] !== $labels ) {
			$shown = array_slice( $labels, 0, 8 );
			$suffix = count( $labels ) > 8 ? '…' : '';
			$summary .= ': ' . implode( ', ', $shown ) . $suffix;
		}

		if ( 'create' === $intent ) {
			// Create mode persists the form and opens it in the editor immediately,
			// so it must not claim the change is still pending review.
			return $summary . '. ' . __( 'It is saved and open in the editor — fine-tune anything, then hit Update to save your edits.', 'wp-powersuite' );
		}

		return $summary . '. ' . __( 'Click Update Form to keep the changes, or Undo in the chat to revert.', 'wp-powersuite' );
	}

	/**
	 * @param array<string, mixed> $result
	 * @return list<string>
	 */
	private static function fieldLabelsFromResult( array $result ): array {
		$fields = isset( $result['fields'] ) && is_array( $result['fields'] ) ? $result['fields'] : [];
		$labels = [];

		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$type = sanitize_key( (string) ( $field['type'] ?? 'text' ) );
			if ( in_array( $type, [ 'separator', 'group', 'html' ], true ) ) {
				continue;
			}

			$label = isset( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : '';
			if ( $label !== '' ) {
				$labels[] = $label;
			}
		}

		return $labels;
	}

	public static function intentLabel( string $intent ): string {
		return match ( self::sanitizeIntentStatic( $intent ) ) {
			'design'     => __( 'Design update', 'wp-powersuite' ),
			'add_fields' => __( 'Fields added', 'wp-powersuite' ),
			'replace'    => __( 'Form replaced', 'wp-powersuite' ),
			'create'     => __( 'Form created', 'wp-powersuite' ),
			default      => __( 'Done', 'wp-powersuite' ),
		};
	}

	/**
	 * @param list<string> $items
	 */
	private static function joinList( array $items ): string {
		$items = array_values( array_filter( $items ) );
		$count = count( $items );
		if ( 0 === $count ) {
			return '';
		}
		if ( 1 === $count ) {
			return $items[0];
		}
		$last = array_pop( $items );
		return implode( ', ', $items ) . ' ' . __( 'and', 'wp-powersuite' ) . ' ' . $last;
	}

	private static function sanitizeIntentStatic( string $intent ): string {
		return in_array( $intent, self::INTENTS, true ) ? $intent : 'modify';
	}

	private function sanitizeIntent( string $intent ): string {
		return in_array( $intent, self::INTENTS, true ) ? $intent : 'create';
	}

	/**
	 * @param array<string, mixed> $context
	 * @return array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design: array<string, string>, form_id: int}
	 */
	private function normalizeContext( array $context ): array {
		$title = isset( $context['title'] ) ? sanitize_text_field( (string) $context['title'] ) : '';
		$form_id = isset( $context['form_id'] ) ? max( 0, (int) $context['form_id'] ) : 0;

		$fields = [];
		if ( isset( $context['fields'] ) && is_array( $context['fields'] ) ) {
			foreach ( $context['fields'] as $field ) {
				if ( ! is_array( $field ) ) {
					continue;
				}
				$fields[] = $this->summarizeFieldForContext( $field );
			}
		}

		$settings = [];
		if ( isset( $context['settings'] ) && is_array( $context['settings'] ) ) {
			foreach ( [ 'subject', 'success_message', 'submit_button_text', 'redirect_url', 'enable_autoreply', 'autoreply_subject', 'autoreply_message' ] as $key ) {
				if ( isset( $context['settings'][ $key ] ) ) {
					$settings[ $key ] = in_array( $key, [ 'success_message', 'autoreply_message' ], true )
						? sanitize_textarea_field( (string) $context['settings'][ $key ] )
						: sanitize_text_field( (string) $context['settings'][ $key ] );
				}
			}
		}

		$design = [];
		if ( isset( $context['design'] ) && is_array( $context['design'] ) ) {
			$design = $this->summarizeDesignForContext( $context['design'] );
		}

		return [
			'title'    => $title,
			'fields'   => $fields,
			'settings' => $settings,
			'design'   => $design,
			'form_id'  => $form_id,
		];
	}

	/**
	 * Keep design context compact: non-empty values only, excluding long typography keys when empty.
	 *
	 * @param array<string, mixed> $design
	 * @return array<string, string>
	 */
	private function summarizeDesignForContext( array $design ): array {
		$allowed = array_keys( DesignDefaults::get() );
		$summary = [];

		foreach ( $design as $key => $value ) {
			if ( ! is_string( $key ) || ! in_array( $key, $allowed, true ) ) {
				continue;
			}
			if ( ! is_scalar( $value ) ) {
				continue;
			}
			$str = (string) $value;
			if ( $str === '' && 'custom_css' !== $key ) {
				continue;
			}
			if ( 'custom_css' === $key ) {
				$summary[ $key ] = mb_substr( $str, 0, 2000 );
				continue;
			}
			$summary[ $key ] = sanitize_text_field( $str );
		}

		return $summary;
	}

	/**
	 * @param array<string, mixed> $field
	 * @return array<string, mixed>
	 */
	private function summarizeFieldForContext( array $field ): array {
		$summary = [
			'type'        => isset( $field['type'] ) ? sanitize_key( (string) $field['type'] ) : 'text',
			'label'       => isset( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : '',
			'placeholder' => isset( $field['placeholder'] ) ? sanitize_text_field( (string) $field['placeholder'] ) : '',
			'required'    => ! empty( $field['required'] ),
		];

		if ( ! empty( $field['options'] ) ) {
			$summary['options'] = sanitize_textarea_field( (string) $field['options'] );
		}
		if ( ! empty( $field['width'] ) ) {
			$summary['width'] = sanitize_key( (string) $field['width'] );
		}
		if ( ! empty( $field['description'] ) ) {
			$summary['description'] = sanitize_textarea_field( (string) $field['description'] );
		}
		if ( ! empty( $field['field_id'] ) ) {
			$summary['field_id'] = sanitize_key( (string) $field['field_id'] );
		}

		// Carry the remaining configurable properties so the model preserves them across edits.
		foreach ( [ 'help', 'empty_label', 'checkbox_label', 'min', 'max', 'step', 'default_value', 'maxlength', 'rows', 'minlength', 'default_country', 'file_types', 'max_size' ] as $key ) {
			if ( isset( $field[ $key ] ) && '' !== (string) $field[ $key ] ) {
				$summary[ $key ] = sanitize_text_field( (string) $field[ $key ] );
			}
		}
		if ( ! empty( $field['inline'] ) ) {
			$summary['inline'] = true;
			$summary['cols']   = isset( $field['cols'] ) && '' !== (string) $field['cols'] ? sanitize_text_field( (string) $field['cols'] ) : '3';
		}
		if ( ! empty( $field['multiple'] ) ) {
			$summary['multiple'] = true;
		}
		if ( 'html' === $summary['type'] && ! empty( $field['content'] ) ) {
			$summary['content'] = wp_kses_post( (string) $field['content'] );
		}
		if ( ! empty( $field['show_if'] ) && is_array( $field['show_if'] ) ) {
			$normalized = ConditionalFields::normalizeShowIf( $field['show_if'] );
			if ( $normalized !== null ) {
				$summary['show_if'] = $normalized;
			}
		}

		return $summary;
	}

	private function buildSystemPrompt( string $intent ): string {
		$types       = implode( ', ', self::ALLOWED_TYPES );
		$design_help = $this->designPromptFragment();
		$role        = 'You are the AI form assistant for the WP PowerSuite "Basic Forms" builder — an expert form architect who turns natural-language requests into precise, production-ready form changes.';

		if ( 'auto' === $intent ) {
			return $role
				. "\n\n### THINK FIRST"
				. ' Silently classify the request as one of: add_fields | edit_fields | remove_fields | design | settings | title | mixed.'
				. ' Then apply the matching rule. If unsure whether to remove a field, KEEP it.'
				. "\n\n### OUTPUT"
				. ' Return ONLY valid JSON — no markdown fences, no commentary. The object MUST contain ONLY the parts you are changing, chosen from:'
				. ' "title" (string), "fields" (array), "fields_mode" ("append"|"merge"|"replace"), "settings" (object), "design" (object), "design_reset" (boolean), "message" (one short sentence describing what you changed, written for the user).'
				. "\n\n### WHAT TO RETURN"
				. ' You MAY combine design/settings with field changes in one response. Always do everything the request asks for.'
				. ' - ADD fields (and nothing else about existing ones): return ONLY the new fields and "fields_mode":"append". Do NOT list existing fields. Do NOT omit or replace them — the builder will keep them.'
				. ' - EDIT existing fields (rename, required, options, width, inline, conditionals): return the COMPLETE fields array with every existing field_id still present, plus "fields_mode":"merge".'
				. ' - REMOVE fields: return the COMPLETE fields array minus ONLY the fields the user named, plus "fields_mode":"replace". Never drop a field they did not name.'
				. ' - Appearance only: include "design" and do NOT return "fields".'
				. ' - Email/success/redirect: include "settings".'
				. ' - Design reset: return {"design_reset":true,"message":"..."} with NO "design" object.'
				. "\n\n### FIELD SCHEMA"
				. ' {"id":0,"type":"text","label":"","placeholder":"","description":"","options":"","required":false,"width":"","field_id":"name_0"}. Allowed types: ' . $types . '. Use sequential numeric ids from 0. For select/radio/checkbox put options one per line in "options".'
				. $this->fieldPropertiesFragment()
				. "\n\n### WIDTH"
				. ' The form container is narrow, so default to full width (width ""). Allowed: ""(full), "half", "third", "quarter", "40", "60". "Make all fields full width" means set every field width to "".'
				. ' Use type "group" for section headings (its "label" is the heading) and "separator" for divider lines.'
				. "\n\n### SETTINGS KEYS"
				. ' subject (admin email subject), success_message, submit_button_text, redirect_url, enable_autoreply ("1"/""), autoreply_subject, autoreply_message.'
				. "\n\n### DESIGN" . $design_help
				. ' GROUP/SECTION heading color is NOT a design key — change it with scoped custom_css targeting .wpps-bf-group-title.'
				. ' DARK THEME: on a dark background you MUST also set light label_color, field_text_color, help_text_color and add custom_css so group titles and option/legend text stay readable. Never leave dark text on a dark background.'
				. "\n\n### HARD RULES"
				. ' Existing fields are protected. Never drop, rename the field_id of, or replace an existing field unless the user explicitly asked. Never invent a success message if you did not change anything — return ONLY {"message":"..."}.'
				. $this->fewShotFragment( 'auto' );
		}

		if ( 'design' === $intent ) {
			return $role
				. "\n\n### OUTPUT"
				. ' Return ONLY valid JSON — no markdown fences, no commentary. Schema: {"design":{"primary_color":"#2271b1","custom_css":".wpps-bf-wrapper.wpps-bf-123 .wpps-bf-form {}"}}.'
				. ' Return ONLY the design keys you are changing. Do NOT return title, fields, or settings.'
				. "\n\n### DESIGN" . $design_help;
		}

		if ( 'add_fields' === $intent ) {
			return $role
				. "\n\n### OUTPUT"
				. ' Return ONLY valid JSON — no markdown fences, no commentary. Schema: {"fields":[{"type":"text","label":"Resume","required":true,"width":"half","field_id":"resume_0","show_if":{"field_id":"case_type_0","operator":"equals","value":"Other"}}],"fields_mode":"append","message":"Added a Resume field."}.'
				. ' Return ONLY the new fields to append. Do not repeat existing fields. Do not remove or rewrite existing fields — the builder keeps them automatically.'
				. ' Choose sensible types and labels from the request (phone → phone, email → email, date → date). Allowed types: ' . $types . '. Use new field_id slugs and omit numeric id.'
				. $this->fieldPropertiesFragment()
				. $this->fewShotFragment( 'add_fields' );
		}

		$base = $role
			. "\n\n### OUTPUT"
			. ' Return ONLY valid JSON — no markdown fences, no commentary. Schema: {"title":"string","fields":[{"id":0,"type":"text","label":"Name","placeholder":"","description":"","options":"","required":false,"width":"half","field_id":"name_0","maxlength":"","show_if":{"field_id":"case_type_0","operator":"equals","value":"Other"}}],"settings":{"subject":"string","success_message":"string","submit_button_text":"string","enable_autoreply":"1","autoreply_subject":"string","autoreply_message":"string"},"design":{"primary_color":"#2271b1"}}.'
			. "\n\n### FIELDS"
			. ' Allowed field types: ' . $types . '. Use sequential numeric ids starting at 0. For select, radio, and checkbox fields put options one per line in the options string. Use group for section headings and separator for visual breaks when helpful.'
			. $this->fieldPropertiesFragment()
			. "\n\n### SETTINGS"
			. ' settings.subject is the admin notification email subject; success_message is shown after submit; submit_button_text should be action-oriented. When the user wants a confirmation email, set enable_autoreply to "1" and include autoreply_subject and autoreply_message.'
			. "\n\n### RULES"
			. ' Fields may use show_if for conditional visibility: {"field_id":"controller_field_id","operator":"equals|not_equals|contains|not_empty|empty","value":"Other"}. Use it only when it improves UX (e.g. show an explain field when Other is selected). Respect explicit field-count limits (e.g. max 10 fields) and add maxlength on text/textarea when limits are requested. Include design only when the user asks for styling, colors, layout, or appearance changes.'
			. $design_help
			. $this->fewShotFragment( $intent );

		if ( 'modify' === $intent ) {
			return $base . "\n\n### MODE You are modifying an existing form. Keep fields the user did not ask to change and apply only the requested edits while preserving overall structure when sensible.";
		}

		if ( 'replace' === $intent ) {
			return $base . "\n\n### MODE Replace the entire form with a fresh schema based on the user request.";
		}

		return $base . "\n\n### MODE Prefer practical labels, placeholders, and required flags. Keep forms focused unless the prompt clearly needs more fields.";
	}

	/**
	 * Compact worked examples that steer the model toward correct, complete output.
	 */
	private function fewShotFragment( string $intent ): string {
		if ( 'auto' === $intent ) {
			return "\n\n### EXAMPLES"
				. ' User: "add a phone number" -> {"fields":[{"type":"phone","label":"Phone Number","required":false,"field_id":"phone_0"}],"fields_mode":"append","message":"Added a Phone Number field."}.'
				. ' User: "show the checkboxes side by side in 3 columns" -> FULL fields array (every existing field_id kept) with that checkbox set inline:true, cols:"3", "fields_mode":"merge".'
				. ' User: "remove the guest count field" -> FULL fields array minus only that field, "fields_mode":"replace".'
				. ' User: "dark theme" -> {"design":{"bg_color":"#111827","input_bg":"#1f2937","input_border_color":"#374151","label_color":"#f9fafb","field_text_color":"#f9fafb","help_text_color":"#d1d5db","primary_color":"#3b82f6","custom_css":".wpps-bf-wrapper.wpps-bf-{form_id} .wpps-bf-group-title{color:#f9fafb}"},"message":"Applied a dark theme with readable light text."}.';
		}

		if ( 'add_fields' === $intent ) {
			return "\n\n### EXAMPLES"
				. ' User: "add name and email, name half width" -> {"fields":[{"type":"text","label":"Name","required":true,"width":"half","field_id":"name_0"},{"type":"email","label":"Email","required":true,"field_id":"email_0"}],"fields_mode":"append"}.'
				. ' User: "add 2 more fields related to this RSVP" -> two NEW fields only (e.g. phone + meal preference), never the existing ones.';
		}

		// create / modify / replace.
		return "\n\n### EXAMPLE"
			. ' User: "a simple contact form" -> {"title":"Contact Us","fields":[{"id":0,"type":"text","label":"Name","required":true,"field_id":"name_0"},{"id":1,"type":"email","label":"Email","required":true,"field_id":"email_1"},{"id":2,"type":"textarea","label":"Message","required":true,"rows":"5","field_id":"message_2"}],"settings":{"subject":"New contact form submission","success_message":"Thanks! We will be in touch soon.","submit_button_text":"Send message"}}.';
	}

	/**
	 * Full reference of supported field properties so the model can use every builder feature.
	 */
	private function fieldPropertiesFragment(): string {
		return ' FIELD PROPERTIES (set only those relevant to the field type):'
			. ' label, placeholder, description, help (small text under the field), required (bool), width (""=full, half, third, quarter, 40, 60), field_id (slug, keep stable when editing).'
			. ' select/radio/checkbox: options (one choice per line).'
			. ' radio & checkbox groups: inline (bool, render choices side by side) and cols ("2"-"5", columns when inline). To "inline" or "put in columns" set inline:true (and cols).'
			. ' single checkbox without options: checkbox_label (the consent text).'
			. ' select: empty_label (first/placeholder option text).'
			. ' number/date/time/datetime: min, max. number also: step, default_value.'
			. ' text/email/url: maxlength, default_value. textarea: rows (2-20), maxlength.'
			. ' password: minlength. phone: default_country (numeric calling code, e.g. "1").'
			. ' file: file_types (e.g. "image/*,.pdf,.docx"), max_size (MB), multiple (bool).'
			. ' html: content (HTML markup). Layout-only types: group (section heading via its label) and separator (divider line).'
			. ' CONDITIONAL VISIBILITY via show_if — single rule {"field_id":"controller_id","operator":"equals|not_equals|contains|not_empty|empty","value":"Other"},'
			. ' or multiple rules {"relation":"and"|"or","rules":[{rule},{rule}]}. equals/not_equals/contains match case-insensitively.';
	}

	private function designPromptFragment(): string {
		return ' Design keys (include only what you change): layout (default|full|compact|narrow|custom), max_width, align (left|center|right), field_spacing (tight|normal|relaxed|spacious), field_gap, bg_color, padding, border_width, border_color, border_radius, label_position (above|left), label_color, label_font_weight, primary_color, button_hover_color, button_style (solid|outline|flat), button_size (small|medium|large), button_border_radius, button_full_width (0|1), button_shadow (none|sm|md|lg), input_border_color, input_bg, input_border_radius, input_padding, placeholder_color, field_text_color, help_text_color, success_msg_bg, success_msg_color, error_msg_bg, error_msg_color, custom_css.'
			. ' Use hex colors. For effects not covered by these options (gradients, animations, advanced layout), add or update custom_css.'
			. ' Scope every custom_css selector to .wpps-bf-wrapper.wpps-bf-{form_id} using the form_id from context when provided.';
	}

	/**
	 * @param array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design: array<string, string>, form_id: int} $context
	 */
	private function buildUserPrompt( string $prompt, string $intent, array $context ): string {
		if ( 'create' === $intent ) {
			return 'Create a form for: ' . $prompt;
		}

		if ( 'auto' === $intent ) {
			$context_json = wp_json_encode( $context, JSON_UNESCAPED_UNICODE );
			return $this->protectedFieldsBlock( $context )
				. "Here is the current form as JSON (fields, settings, design, form_id):\n"
				. $context_json
				. "\n\nUser request:\n" . $prompt
				. "\n\nIf this is an add request, return only the new fields with fields_mode=append. Never drop a protected field unless the user named it for removal.";
		}

		if ( 'design' === $intent ) {
			$context_json = wp_json_encode(
				[
					'form_id' => $context['form_id'],
					'design'  => $context['design'],
				],
				JSON_UNESCAPED_UNICODE
			);
			return "Update the form design based on the request.\n\nCurrent design:\n"
				. $context_json
				. "\n\nUser request: " . $prompt;
		}

		if ( 'replace' === $intent ) {
			$context_json = wp_json_encode( $context, JSON_UNESCAPED_UNICODE );
			return "Replace this form entirely based on the request.\n\nCurrent form (for reference):\n"
				. $context_json
				. "\n\nUser request: " . $prompt;
		}

		if ( 'add_fields' === $intent ) {
			$fields_json = wp_json_encode( $context['fields'] ?? [], JSON_UNESCAPED_UNICODE );
			return $this->protectedFieldsBlock( $context )
				. "Existing fields (do not duplicate, do not return them, do not remove them):\n"
				. $fields_json
				. "\n\nAdd only NEW fields for: " . $prompt;
		}

		$context_json = wp_json_encode( $context, JSON_UNESCAPED_UNICODE );
		return "Modify this existing form based on the user request.\n\nCurrent form:\n"
			. $context_json
			. "\n\nUser request: " . $prompt;
	}

	/**
	 * @param list<array{role: string, content: string}> $history
	 * @param array{temperature?: float, max_tokens?: int} $options
	 */
	private function callProvider( string $provider, string $model, string $api_key, string $system_prompt, string $user_prompt, array $history = [], array $options = [] ): string|\WP_Error {
		return match ( ProviderRegistry::apiFamily( $provider ) ) {
			'gemini'            => $this->callGemini( $model, $api_key, $system_prompt, $user_prompt, $history, $options ),
			'claude'            => $this->callClaude( $model, $api_key, $system_prompt, $user_prompt, $history, $options ),
			'openai_compatible' => $this->callOpenAiCompatible( $provider, $model, $api_key, $system_prompt, $user_prompt, $history, $options ),
			default             => $this->callOpenAi( $model, $api_key, $system_prompt, $user_prompt, $history, $options ),
		};
	}

	/**
	 * Stream the completion, forwarding only the natural-language preamble (the text
	 * before the fenced JSON block) to $on_text. Returns the full raw content so the
	 * caller can extract and normalize the JSON afterwards.
	 *
	 * @param list<array{role: string, content: string}> $history
	 * @param array{temperature?: float, max_tokens?: int} $tuning
	 */
	private function callProviderStreaming( string $provider, string $model, string $api_key, string $system_prompt, string $user_prompt, array $history, array $tuning, callable $on_text ): string|\WP_Error {
		$messages      = $this->buildApiMessages( $system_prompt, $history, $user_prompt );
		$extra_headers = 'openrouter' === $provider ? OpenAiCompatibleClient::openRouterHeaders() : [];

		$reached_json = false;
		$emitted_len  = 0;

		$on_delta = static function ( string $delta, string $full ) use ( &$reached_json, &$emitted_len, $on_text ): void {
			if ( $reached_json ) {
				return;
			}

			// Only the human-readable preamble should reach the chat. The JSON payload
			// begins at the first fence or the first "{"/"[", so stop streaming there —
			// this prevents raw JSON from ever leaking into the message when the model
			// skips the preamble and returns JSON directly.
			$boundary = null;
			foreach ( [ '```', '{', '[' ] as $marker ) {
				$pos = strpos( $full, $marker );
				if ( false !== $pos && ( null === $boundary || $pos < $boundary ) ) {
					$boundary = $pos;
				}
			}

			if ( null !== $boundary ) {
				$reached_json = true;
				$text         = substr( $full, 0, $boundary );
				if ( strlen( $text ) > $emitted_len ) {
					call_user_func( $on_text, substr( $text, $emitted_len ) );
					$emitted_len = strlen( $text );
				}
				return;
			}

			call_user_func( $on_text, $delta );
			$emitted_len = strlen( $full );
		};

		return OpenAiCompatibleClient::streamChatCompletions(
			ProviderRegistry::baseUrl( $provider ),
			$api_key,
			$model,
			$messages,
			$on_delta,
			[
				'json_mode'     => true,
				'temperature'   => (float) ( $tuning['temperature'] ?? 0.4 ),
				'max_tokens'    => (int) ( $tuning['max_tokens'] ?? 4096 ),
				'extra_headers' => $extra_headers,
			]
		);
	}

	/**
	 * Per-intent generation tuning. Full-form builds can be large and benefit from
	 * more room and a touch more creativity; edits stay tighter and more deterministic.
	 *
	 * @return array{temperature: float, max_tokens: int}
	 */
	private function generationTuning( string $intent ): array {
		return match ( $intent ) {
			'create', 'replace' => [ 'temperature' => 0.5, 'max_tokens' => 8192 ],
			'design'            => [ 'temperature' => 0.3, 'max_tokens' => 4096 ],
			'add_fields'        => [ 'temperature' => 0.2, 'max_tokens' => 4096 ],
			default             => [ 'temperature' => 0.2, 'max_tokens' => 8192 ],
		};
	}

	/**
	 * @param list<array{role: string, content: string}> $history
	 * @return list<array{role: string, content: string}>
	 */
	private function buildApiMessages( string $system_prompt, array $history, string $user_prompt ): array {
		$messages = [
			[
				'role'    => 'system',
				'content' => $system_prompt,
			],
		];

		foreach ( $history as $message ) {
			$messages[] = $message;
		}

		$messages[] = [
			'role'    => 'user',
			'content' => $user_prompt,
		];

		return $messages;
	}

	/**
	 * @param list<array{role: string, content: string}> $history
	 */
	private function callOpenAi( string $model, string $api_key, string $system_prompt, string $user_prompt, array $history = [], array $options = [] ): string|\WP_Error {
		return $this->callOpenAiCompatible( 'openai', $model, $api_key, $system_prompt, $user_prompt, $history, $options );
	}

	/**
	 * @param list<array{role: string, content: string}> $history
	 * @param array{temperature?: float, max_tokens?: int} $options
	 */
	private function callOpenAiCompatible( string $provider, string $model, string $api_key, string $system_prompt, string $user_prompt, array $history = [], array $options = [] ): string|\WP_Error {
		$messages     = $this->buildApiMessages( $system_prompt, $history, $user_prompt );
		$extra_headers = $provider === 'openrouter' ? OpenAiCompatibleClient::openRouterHeaders() : [];

		return OpenAiCompatibleClient::chatCompletions(
			ProviderRegistry::baseUrl( $provider ),
			$api_key,
			$model,
			$messages,
			[
				'json_mode'     => array_key_exists( 'json_mode', $options ) ? (bool) $options['json_mode'] : true,
				'temperature'   => (float) ( $options['temperature'] ?? 0.4 ),
				'max_tokens'    => (int) ( $options['max_tokens'] ?? 4096 ),
				'extra_headers' => $extra_headers,
			]
		);
	}

	/**
	 * @param array{temperature?: float, max_tokens?: int} $options
	 */
	private function callGemini( string $model, string $api_key, string $system_prompt, string $user_prompt, array $history = [], array $options = [] ): string|\WP_Error {
		$url = sprintf(
			'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent',
			rawurlencode( $model )
		);

		$contents = [];
		foreach ( $history as $message ) {
			$contents[] = [
				'role'  => 'assistant' === $message['role'] ? 'model' : 'user',
				'parts' => [
					[ 'text' => $message['content'] ],
				],
			];
		}
		$contents[] = [
			'role'  => 'user',
			'parts' => [
				[ 'text' => $user_prompt ],
			],
		];

		$response = wp_remote_post(
			$url,
			[
				'timeout' => 90,
				'headers' => [
					'Content-Type'   => 'application/json',
					'x-goog-api-key' => $api_key,
				],
				'body'    => wp_json_encode(
					[
						'system_instruction' => [
							'parts' => [
								[ 'text' => $system_prompt ],
							],
						],
						'contents'           => $contents,
						'generationConfig'   => array_filter(
							[
								'temperature'      => (float) ( $options['temperature'] ?? 0.4 ),
								'maxOutputTokens'  => (int) ( $options['max_tokens'] ?? 4096 ),
								'responseMimeType' => ( array_key_exists( 'json_mode', $options ) && ! $options['json_mode'] ) ? null : 'application/json',
							],
							static fn ( $value ) => null !== $value
						),
					]
				),
			]
		);

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

		$code = wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		if ( $code !== 200 || ! is_array( $body ) ) {
			return new \WP_Error( 'api_error', __( 'Gemini request failed.', 'wp-powersuite' ) );
		}

		$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '';
		return is_string( $text ) && $text !== '' ? $text : new \WP_Error( 'empty_response', __( 'AI returned an empty response.', 'wp-powersuite' ) );
	}

	/**
	 * @param array{temperature?: float, max_tokens?: int} $options
	 */
	private function callClaude( string $model, string $api_key, string $system_prompt, string $user_prompt, array $history = [], array $options = [] ): string|\WP_Error {
		$messages = [];
		foreach ( $history as $message ) {
			$messages[] = $message;
		}
		$messages[] = [
			'role'    => 'user',
			'content' => $user_prompt,
		];

		$response = wp_remote_post(
			'https://api.anthropic.com/v1/messages',
			[
				'timeout' => 90,
				'headers' => [
					'x-api-key'         => $api_key,
					'anthropic-version' => '2023-06-01',
					'Content-Type'      => 'application/json',
				],
				'body'    => wp_json_encode(
					[
						'model'       => $model,
						'max_tokens'  => (int) ( $options['max_tokens'] ?? 4096 ),
						'system'      => $system_prompt,
						'temperature' => (float) ( $options['temperature'] ?? 0.4 ),
						'messages'    => $messages,
					]
				),
			]
		);

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

		$code = wp_remote_retrieve_response_code( $response );
		$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
		if ( $code !== 200 || ! is_array( $body ) ) {
			$message = is_array( $body ) ? (string) ( $body['error']['message'] ?? __( 'Claude request failed.', 'wp-powersuite' ) ) : __( 'Claude request failed.', 'wp-powersuite' );
			return new \WP_Error( 'api_error', $message );
		}

		$blocks = $body['content'] ?? [];
		if ( ! is_array( $blocks ) ) {
			return new \WP_Error( 'empty_response', __( 'AI returned an empty response.', 'wp-powersuite' ) );
		}

		$text = '';
		foreach ( $blocks as $block ) {
			if ( is_array( $block ) && ( $block['type'] ?? '' ) === 'text' && isset( $block['text'] ) ) {
				$text .= (string) $block['text'];
			}
		}

		return $text !== '' ? $text : new \WP_Error( 'empty_response', __( 'AI returned an empty response.', 'wp-powersuite' ) );
	}

	/**
	 * @return array<string, mixed>|\WP_Error
	 */
	private function parsePayload( string $raw ): array|\WP_Error {
		$raw = trim( $raw );
		if ( preg_match( '/```(?:json)?\s*(.*?)\s*```/s', $raw, $matches ) ) {
			$raw = trim( (string) $matches[1] );
		}

		$decoded = json_decode( $raw, true );
		if ( ! is_array( $decoded ) ) {
			return new \WP_Error( 'invalid_json', __( 'AI returned an invalid form structure. Try again with a clearer prompt.', 'wp-powersuite' ) );
		}

		return $decoded;
	}

	/**
	 * Whether a parsed payload carries an actionable change for the given intent.
	 * A bare {"message":"..."} in auto mode is intentional (the model declined), so
	 * it counts as usable; everything else must contain fields/settings/design/title.
	 *
	 * @param array<string, mixed>|\WP_Error $payload
	 */
	private function payloadHasUsableChange( array|\WP_Error $payload, string $intent ): bool {
		if ( is_wp_error( $payload ) ) {
			return false;
		}

		$has_fields   = isset( $payload['fields'] ) && is_array( $payload['fields'] ) && [] !== $payload['fields'];
		$has_settings = isset( $payload['settings'] ) && is_array( $payload['settings'] ) && [] !== $payload['settings'];
		$has_design   = ( isset( $payload['design'] ) && is_array( $payload['design'] ) && [] !== $payload['design'] )
			|| ! empty( $payload['design_reset'] );
		$has_title    = isset( $payload['title'] ) && '' !== trim( (string) $payload['title'] );

		if ( 'auto' === $intent ) {
			// The model is allowed to decline with just a message.
			$has_message = isset( $payload['message'] ) && '' !== trim( (string) $payload['message'] );
			return $has_fields || $has_settings || $has_design || $has_title || $has_message;
		}

		if ( 'design' === $intent ) {
			return $has_design || ( isset( $payload['primary_color'] ) || isset( $payload['custom_css'] ) );
		}

		// create / modify / replace / add_fields all need at least one field.
		return $has_fields;
	}

	/**
	 * One corrective pass when the model returns malformed JSON or an empty change.
	 * Re-asks the model to fix its own output before we give up.
	 *
	 * @param list<array{role: string, content: string}> $history
	 * @param array{temperature?: float, max_tokens?: int} $options
	 * @return array<string, mixed>|\WP_Error
	 */
	private function repairPayload(
		string $provider,
		string $model,
		string $api_key,
		string $system_prompt,
		string $raw,
		string $reason,
		array $history,
		array $options = []
	): array|\WP_Error {
		$snippet = mb_substr( trim( $raw ), 0, 6000 );
		$repair_prompt = $reason
			. " Return ONLY corrected, valid JSON that follows the schema and actually applies the requested change. Do not add commentary or markdown fences.\n\nPrevious response:\n"
			. $snippet;

		$repaired = $this->callProvider( $provider, $model, $api_key, $system_prompt, $repair_prompt, $history, $options );
		if ( is_wp_error( $repaired ) ) {
			return new \WP_Error( 'invalid_json', __( 'AI returned an invalid form structure. Try again with a clearer prompt.', 'wp-powersuite' ) );
		}

		$payload = $this->parsePayload( $repaired );
		if ( is_wp_error( $payload ) ) {
			return new \WP_Error( 'invalid_json', __( 'AI returned an invalid form structure. Try again with a clearer prompt.', 'wp-powersuite' ) );
		}

		return $payload;
	}

	/**
	 * Human-readable inventory the model must not drop unless asked.
	 *
	 * @param array{fields?: array<int, array<string, mixed>>} $context
	 */
	private function protectedFieldsBlock( array $context ): string {
		$lines = [];
		$n     = 0;
		foreach ( ( $context['fields'] ?? [] ) as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}
			++$n;
			$label = sanitize_text_field( (string) ( $field['label'] ?? '' ) );
			$type  = sanitize_key( (string) ( $field['type'] ?? 'text' ) );
			$fid   = sanitize_key( (string) ( $field['field_id'] ?? '' ) );
			$lines[] = $n . '. "' . ( $label !== '' ? $label : $type ) . '" (type=' . $type . ( $fid !== '' ? ', field_id=' . $fid : '' ) . ')';
		}

		if ( [] === $lines ) {
			return '';
		}

		return "PROTECTED FIELDS — keep every one of these unless the user named it for removal:\n"
			. implode( "\n", $lines )
			. "\n\n";
	}

	/**
	 * Prevent the model from silently dropping existing fields on add/edit.
	 *
	 * @param array<string, mixed> $result
	 * @param array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design: array<string, string>, form_id: int} $context
	 * @return array<string, mixed>
	 */
	private function protectExistingFields( array $result, string $intent, array $context, string $prompt ): array {
		if ( ! in_array( $intent, [ 'auto', 'modify', 'add_fields' ], true ) ) {
			if ( 'replace' === $intent ) {
				$result['fields_mode'] = 'replace';
			}
			return $result;
		}

		$existing = isset( $context['fields'] ) && is_array( $context['fields'] ) ? $context['fields'] : [];
		$incoming = isset( $result['fields'] ) && is_array( $result['fields'] ) ? $result['fields'] : [];

		if ( 'add_fields' === $intent ) {
			$result['fields']      = [] === $existing ? $incoming : $this->onlyNewFields( $existing, $incoming );
			$result['fields_mode'] = 'append';
			return $result;
		}

		if ( [] === $incoming || [] === $existing ) {
			return $result;
		}

		if ( self::isReplaceRequest( $prompt ) ) {
			$result['fields_mode'] = 'replace';
			return $result;
		}

		$allowed_removals = $this->namedRemovalsFromPrompt( $prompt, $existing );

		if ( [] === $allowed_removals && ( self::isAddOnlyRequest( $prompt ) || $this->looksLikeAppend( $existing, $incoming ) ) ) {
			$result['fields']      = $this->onlyNewFields( $existing, $incoming );
			$result['fields_mode'] = 'append';
			if ( ! isset( $result['changed'] ) || ! is_array( $result['changed'] ) ) {
				$result['changed'] = [ 'fields' ];
			} elseif ( ! in_array( 'fields', $result['changed'], true ) ) {
				$result['changed'][] = 'fields';
			}
			return $result;
		}

		$result['fields']      = $this->mergeFieldLists( $existing, $incoming, $allowed_removals );
		$result['fields_mode'] = [] !== $allowed_removals ? 'replace' : 'merge';
		return $result;
	}

	/**
	 * @param array<int, array<string, mixed>> $existing
	 * @param array<int, array<string, mixed>> $incoming
	 */
	private function looksLikeAppend( array $existing, array $incoming ): bool {
		$existing_keys = [];
		foreach ( $existing as $field ) {
			$key = $this->fieldMatchKey( $field );
			if ( $key !== '' ) {
				$existing_keys[ $key ] = true;
			}
		}

		$overlap = 0;
		foreach ( $incoming as $field ) {
			$key = $this->fieldMatchKey( $field );
			if ( $key !== '' && isset( $existing_keys[ $key ] ) ) {
				++$overlap;
			}
		}

		return 0 === $overlap;
	}

	/**
	 * @param array<int, array<string, mixed>> $existing
	 * @param array<int, array<string, mixed>> $incoming
	 * @return array<int, array<string, mixed>>
	 */
	private function onlyNewFields( array $existing, array $incoming ): array {
		$existing_keys   = [];
		$existing_labels = [];
		foreach ( $existing as $field ) {
			$key = $this->fieldMatchKey( $field );
			if ( $key !== '' ) {
				$existing_keys[ $key ] = true;
			}
			$label = mb_strtolower( trim( (string) ( $field['label'] ?? '' ) ) );
			if ( $label !== '' ) {
				$existing_labels[ $label ] = true;
			}
		}

		$out = [];
		foreach ( $incoming as $field ) {
			$key   = $this->fieldMatchKey( $field );
			$label = mb_strtolower( trim( (string) ( $field['label'] ?? '' ) ) );
			if ( $key !== '' && isset( $existing_keys[ $key ] ) ) {
				continue;
			}
			if ( $label !== '' && isset( $existing_labels[ $label ] ) ) {
				continue;
			}
			$out[] = $field;
		}

		return $out;
	}

	/**
	 * @param array<int, array<string, mixed>> $existing
	 * @param array<int, array<string, mixed>> $incoming
	 * @param array<string, true>              $allowed_removals
	 * @return array<int, array<string, mixed>>
	 */
	private function mergeFieldLists( array $existing, array $incoming, array $allowed_removals ): array {
		$incoming_by_key = [];
		$incoming_new    = [];
		foreach ( $incoming as $field ) {
			$key = $this->fieldMatchKey( $field );
			if ( $key !== '' && $this->existingHasKey( $existing, $key ) ) {
				$incoming_by_key[ $key ] = $field;
			} else {
				$incoming_new[] = $field;
			}
		}

		$out = [];
		foreach ( $existing as $field ) {
			$key = $this->fieldMatchKey( $field );
			if ( $key !== '' && isset( $allowed_removals[ $key ] ) ) {
				continue;
			}
			if ( $key !== '' && isset( $incoming_by_key[ $key ] ) ) {
				$out[] = $this->overlayField( $field, $incoming_by_key[ $key ] );
			} else {
				$out[] = $field;
			}
		}

		foreach ( $incoming_new as $field ) {
			$out[] = $field;
		}

		return $this->normalizeFieldsList( $out, 0 );
	}

	/**
	 * @param array<int, array<string, mixed>> $existing
	 */
	private function existingHasKey( array $existing, string $key ): bool {
		foreach ( $existing as $field ) {
			if ( $this->fieldMatchKey( $field ) === $key ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array<string, mixed> $existing
	 * @param array<string, mixed> $incoming
	 * @return array<string, mixed>
	 */
	private function overlayField( array $existing, array $incoming ): array {
		$merged = $incoming;
		if ( ! empty( $existing['field_id'] ) ) {
			$merged['field_id'] = $existing['field_id'];
		}
		if ( isset( $existing['id'] ) ) {
			$merged['id'] = $existing['id'];
		}
		return $merged;
	}

	/**
	 * @param array<string, mixed> $field
	 */
	private function fieldMatchKey( array $field ): string {
		$fid = sanitize_key( (string) ( $field['field_id'] ?? '' ) );
		if ( $fid !== '' ) {
			return 'fid:' . $fid;
		}
		$label = mb_strtolower( trim( (string) ( $field['label'] ?? '' ) ) );
		$type  = sanitize_key( (string) ( $field['type'] ?? 'text' ) );
		if ( $label !== '' ) {
			return 'lt:' . $type . ':' . $label;
		}
		return '';
	}

	/**
	 * @param array<int, array<string, mixed>> $existing
	 * @return array<string, true>
	 */
	private function namedRemovalsFromPrompt( string $prompt, array $existing ): array {
		$p = mb_strtolower( $prompt );
		if ( ! self::isRemoveRequest( $p ) ) {
			return [];
		}

		$keys = [];
		foreach ( $existing as $field ) {
			$label = mb_strtolower( trim( (string) ( $field['label'] ?? '' ) ) );
			$fid   = sanitize_key( (string) ( $field['field_id'] ?? '' ) );
			if ( $label !== '' && str_contains( $p, $label ) ) {
				$key = $this->fieldMatchKey( $field );
				if ( $key !== '' ) {
					$keys[ $key ] = true;
				}
			} elseif ( $fid !== '' && str_contains( $p, str_replace( '_', ' ', $fid ) ) ) {
				$key = $this->fieldMatchKey( $field );
				if ( $key !== '' ) {
					$keys[ $key ] = true;
				}
			}
		}

		return $keys;
	}

	/**
	 * @param array<string, mixed> $payload
	 * @param array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design: array<string, string>, form_id: int} $context
	 * @return array{title: string, fields: array<int, array<string, mixed>>, settings: array<string, string>, design?: array<string, string>}
	 */
	private function normalizePayload( array $payload, string $intent, array $context ): array {
		if ( 'auto' === $intent ) {
			$changed = [];

			$has_fields = isset( $payload['fields'] ) && is_array( $payload['fields'] ) && [] !== $payload['fields'];
			if ( $has_fields ) {
				$fields = $this->normalizeFieldsList( $payload['fields'], 0 );
				$changed[] = 'fields';
			} else {
				$fields = $this->normalizeFieldsList( $context['fields'], 0 );
			}

			$title = isset( $payload['title'] ) ? sanitize_text_field( (string) $payload['title'] ) : '';
			if ( $title !== '' && $title !== $context['title'] ) {
				$changed[] = 'title';
			}
			if ( $title === '' ) {
				$title = $context['title'] !== '' ? $context['title'] : __( 'New Form', 'wp-powersuite' );
			}

			$settings = $context['settings'];
			if ( isset( $payload['settings'] ) && is_array( $payload['settings'] ) && [] !== $payload['settings'] ) {
				$settings = $this->extractSettings( $payload['settings'], $context['settings'] );
				$changed[] = 'settings';
			}

			$result = [
				'title'    => $title,
				'fields'   => $fields,
				'settings' => $settings,
				'changed'  => $changed,
				'ai_message' => isset( $payload['message'] ) ? sanitize_text_field( (string) $payload['message'] ) : '',
			];

			$design_reset = ! empty( $payload['design_reset'] )
				&& ! in_array( $payload['design_reset'], [ '0', 'false', 'no' ], true );
			if ( $design_reset ) {
				$result['design']    = DesignDefaults::get();
				$result['changed'][] = 'design';
				if ( '' === $result['ai_message'] ) {
					$result['ai_message'] = __( 'Reset the form design to the default theme.', 'wp-powersuite' );
				}
			} elseif ( isset( $payload['design'] ) && is_array( $payload['design'] ) && [] !== $payload['design'] ) {
				$normalized_design = $this->normalizeDesign( $payload['design'], $context['design'] );
				if ( [] !== $normalized_design ) {
					$result['design'] = $normalized_design;
					$result['changed'][] = 'design';
				}
			}

			return $result;
		}

		if ( 'design' === $intent ) {
			$raw_design = isset( $payload['design'] ) && is_array( $payload['design'] ) ? $payload['design'] : $payload;

			return [
				'title'    => $context['title'] !== '' ? $context['title'] : __( 'New Form', 'wp-powersuite' ),
				'fields'   => [],
				'settings' => $context['settings'],
				'design'   => $this->normalizeDesign( is_array( $raw_design ) ? $raw_design : [], $context['design'] ),
			];
		}

		if ( 'add_fields' === $intent ) {
			$fields = [];
			if ( isset( $payload['fields'] ) && is_array( $payload['fields'] ) ) {
				$fields = $payload['fields'];
			} elseif ( isset( $payload['type'] ) && is_array( $payload ) ) {
				$fields = [ $payload ];
			}

			$result = [
				'title'       => $context['title'] !== '' ? $context['title'] : __( 'New Form', 'wp-powersuite' ),
				'fields'      => $this->normalizeFieldsList( $fields, 0 ),
				'settings'    => $context['settings'],
				'changed'     => [ 'fields' ],
				'fields_mode' => 'append',
				'ai_message'  => isset( $payload['message'] ) ? sanitize_text_field( (string) $payload['message'] ) : '',
			];
			if ( isset( $payload['design'] ) && is_array( $payload['design'] ) ) {
				$normalized_design = $this->normalizeDesign( $payload['design'], $context['design'] );
				if ( [] !== $normalized_design ) {
					$result['design']    = $normalized_design;
					$result['changed'][] = 'design';
				}
			}

			return $result;
		}

		$title  = isset( $payload['title'] ) ? sanitize_text_field( (string) $payload['title'] ) : $context['title'];
		$fields = isset( $payload['fields'] ) && is_array( $payload['fields'] ) ? $payload['fields'] : [];

		$normalized_fields = $this->normalizeFieldsList( $fields, 0 );
		if ( [] === $normalized_fields && [] !== $context['fields'] ) {
			$result = [
				'title'    => $title !== '' ? $title : $context['title'],
				'fields'   => $this->normalizeFieldsList( $context['fields'], 0 ),
				'settings' => $this->mergeSettings( $payload, $context['settings'] ),
			];
			if ( isset( $payload['design'] ) && is_array( $payload['design'] ) ) {
				$result['design'] = $this->normalizeDesign( $payload['design'], $context['design'] );
			}

			return $result;
		}

		$raw_settings = isset( $payload['settings'] ) && is_array( $payload['settings'] ) ? $payload['settings'] : [];

		$result = [
			'title'    => $title !== '' ? $title : ( $context['title'] !== '' ? $context['title'] : __( 'New Form', 'wp-powersuite' ) ),
			'fields'   => $normalized_fields,
			'settings' => $this->extractSettings( $raw_settings, $context['settings'] ),
		];
		if ( isset( $payload['design'] ) && is_array( $payload['design'] ) ) {
			$result['design'] = $this->normalizeDesign( $payload['design'], $context['design'] );
		}

		return $result;
	}

	/**
	 * Merge AI design changes onto existing design context.
	 *
	 * @param array<string, mixed> $raw_design
	 * @param array<string, string> $context_design
	 * @return array<string, string>
	 */
	private function normalizeDesign( array $raw_design, array $context_design ): array {
		$defaults  = DesignDefaults::get();
		$allowed   = array_keys( $defaults );
		$incoming  = [];

		foreach ( $raw_design as $key => $value ) {
			if ( ! is_string( $key ) || ! in_array( $key, $allowed, true ) || ! is_scalar( $value ) ) {
				continue;
			}
			if ( 'custom_css' === $key ) {
				$incoming[ $key ] = $this->sanitizeCustomCss( (string) $value );
				continue;
			}
			$incoming[ $key ] = sanitize_text_field( (string) $value );
		}

		if ( [] === $incoming ) {
			return [];
		}

		$merged = wp_parse_args( $incoming, wp_parse_args( $context_design, $defaults ) );

		return DesignDefaults::normalizeForAdminForm( $merged );
	}

	private function sanitizeCustomCss( string $css ): string {
		if ( $css === '' ) {
			return '';
		}

		$css = preg_replace( '/<script\b[^>]*>(.*?)<\/script>/is', '', $css );
		$css = wp_strip_all_tags( $css, true );
		$css = preg_replace( '/javascript\s*:/i', '', $css );
		$css = preg_replace( '/expression\s*\(/i', '', $css );
		$css = preg_replace( '/@import/i', '', $css );
		$css = preg_replace( '/behavior\s*:/i', '', $css );
		$css = preg_replace( '/<\/?style\b/i', '', $css );
		$css = trim( $css );

		if ( strlen( $css ) > 10000 ) {
			$css = substr( $css, 0, 10000 );
		}

		return $css;
	}

	/**
	 * @param array<int, mixed> $fields
	 * @return array<int, array<string, mixed>>
	 */
	private function normalizeFieldsList( array $fields, int $start_id ): array {
		$next_id           = $start_id;
		$normalized_fields = [];

		foreach ( $fields as $field ) {
			if ( ! is_array( $field ) ) {
				continue;
			}

			$type = isset( $field['type'] ) ? sanitize_key( (string) $field['type'] ) : 'text';
			if ( ! in_array( $type, self::ALLOWED_TYPES, true ) ) {
				$type = 'text';
			}

			$label = isset( $field['label'] ) ? sanitize_text_field( (string) $field['label'] ) : '';
			if ( $label === '' && ! in_array( $type, [ 'separator', 'html' ], true ) ) {
				$label = ucfirst( $type ) . ' Field';
			}

			$item = [
				'id'          => $next_id,
				'type'        => $type,
				'label'       => $label,
				'placeholder' => isset( $field['placeholder'] ) ? sanitize_text_field( (string) $field['placeholder'] ) : '',
				'description' => isset( $field['description'] ) ? sanitize_textarea_field( (string) $field['description'] ) : '',
				'options'     => isset( $field['options'] ) ? sanitize_textarea_field( (string) $field['options'] ) : '',
				'required'    => ! empty( $field['required'] ),
			];

			if ( ! in_array( $type, [ 'separator', 'group', 'html' ], true ) ) {
				$base_slug = sanitize_key( str_replace( '-', '_', sanitize_title( $label ) ) );
				if ( $base_slug === '' ) {
					$base_slug = 'field';
				}
				$item['field_id'] = ! empty( $field['field_id'] ) ? sanitize_key( (string) $field['field_id'] ) : $base_slug . '_' . $next_id;
			}

			if ( isset( $field['width'] ) ) {
				$width = sanitize_key( (string) $field['width'] );
				if ( in_array( $width, [ 'half', 'third', 'quarter', '40', '60' ], true ) ) {
					$item['width'] = $width;
				}
			}

			if ( ! empty( $field['help'] ) ) {
				$item['help'] = sanitize_text_field( (string) $field['help'] );
			}
			if ( isset( $field['maxlength'] ) && (int) $field['maxlength'] > 0 ) {
				$item['maxlength'] = (string) (int) $field['maxlength'];
			}
			if ( isset( $field['rows'] ) && (int) $field['rows'] > 0 ) {
				$item['rows'] = (string) max( 2, min( 20, (int) $field['rows'] ) );
			}
			if ( ! empty( $field['checkbox_label'] ) ) {
				$item['checkbox_label'] = sanitize_text_field( (string) $field['checkbox_label'] );
			}
			if ( 'select' === $type && isset( $field['empty_label'] ) ) {
				$item['empty_label'] = sanitize_text_field( (string) $field['empty_label'] );
			}

			// Radio / checkbox group layout.
			if ( in_array( $type, [ 'checkbox', 'radio' ], true ) && ! empty( $field['inline'] ) ) {
				$item['inline'] = true;
				$cols = isset( $field['cols'] ) ? (int) $field['cols'] : 3;
				$item['cols'] = (string) max( 2, min( 5, $cols > 0 ? $cols : 3 ) );
			}

			// Numeric / date range and step.
			if ( in_array( $type, [ 'number', 'date', 'time', 'datetime' ], true ) ) {
				if ( isset( $field['min'] ) && '' !== (string) $field['min'] ) {
					$item['min'] = sanitize_text_field( (string) $field['min'] );
				}
				if ( isset( $field['max'] ) && '' !== (string) $field['max'] ) {
					$item['max'] = sanitize_text_field( (string) $field['max'] );
				}
			}
			if ( 'number' === $type && isset( $field['step'] ) && '' !== (string) $field['step'] ) {
				$item['step'] = sanitize_text_field( (string) $field['step'] );
			}
			if ( in_array( $type, [ 'text', 'email', 'url', 'number', 'hidden' ], true ) && isset( $field['default_value'] ) && '' !== (string) $field['default_value'] ) {
				$item['default_value'] = sanitize_text_field( (string) $field['default_value'] );
			}
			if ( 'password' === $type && isset( $field['minlength'] ) && (int) $field['minlength'] > 0 ) {
				$item['minlength'] = (string) max( 1, min( 128, (int) $field['minlength'] ) );
			}
			if ( 'phone' === $type && isset( $field['default_country'] ) && '' !== (string) $field['default_country'] ) {
				$item['default_country'] = preg_replace( '/[^0-9]/', '', (string) $field['default_country'] );
			}

			if ( 'file' === $type ) {
				if ( ! empty( $field['file_types'] ) ) {
					$item['file_types'] = sanitize_text_field( (string) $field['file_types'] );
				}
				if ( isset( $field['max_size'] ) && '' !== (string) $field['max_size'] ) {
					$item['max_size'] = sanitize_text_field( (string) $field['max_size'] );
				}
				if ( ! empty( $field['multiple'] ) ) {
					$item['multiple'] = true;
				}
			} elseif ( ! empty( $field['file_types'] ) ) {
				$item['file_types'] = sanitize_text_field( (string) $field['file_types'] );
			}
			if ( isset( $field['show_if'] ) && is_array( $field['show_if'] ) ) {
				$normalized_show_if = ConditionalFields::normalizeShowIf( $field['show_if'] );
				if ( $normalized_show_if !== null ) {
					$item['show_if'] = $normalized_show_if;
				}
			}

			if ( $type === 'html' && isset( $field['content'] ) ) {
				$item['content'] = wp_kses_post( (string) $field['content'] );
			}

			$normalized_fields[] = $item;
			++$next_id;
		}

		if ( [] === $normalized_fields && 0 === $start_id ) {
			$normalized_fields[] = [
				'id'          => 0,
				'type'        => 'textarea',
				'label'       => __( 'Message', 'wp-powersuite' ),
				'placeholder' => '',
				'description' => '',
				'options'     => '',
				'required'    => true,
				'field_id'    => 'message_0',
			];
		}

		return $normalized_fields;
	}

	/**
	 * @param array<string, mixed> $payload
	 * @param array<string, string>  $fallback
	 * @return array<string, string>
	 */
	private function mergeSettings( array $payload, array $fallback ): array {
		$raw_settings = isset( $payload['settings'] ) && is_array( $payload['settings'] ) ? $payload['settings'] : $payload;
		return $this->extractSettings( is_array( $raw_settings ) ? $raw_settings : [], $fallback );
	}

	/**
	 * @param array<string, mixed> $raw_settings
	 * @param array<string, string> $fallback
	 * @return array<string, string>
	 */
	private function extractSettings( array $raw_settings, array $fallback ): array {
		$settings = $fallback;
		foreach ( [ 'subject', 'success_message', 'submit_button_text', 'redirect_url', 'autoreply_subject' ] as $key ) {
			if ( isset( $raw_settings[ $key ] ) && (string) $raw_settings[ $key ] !== '' ) {
				$settings[ $key ] = in_array( $key, [ 'success_message', 'autoreply_message' ], true )
					? sanitize_textarea_field( (string) $raw_settings[ $key ] )
					: sanitize_text_field( (string) $raw_settings[ $key ] );
			}
		}

		if ( isset( $raw_settings['autoreply_message'] ) && (string) $raw_settings['autoreply_message'] !== '' ) {
			$settings['autoreply_message'] = sanitize_textarea_field( (string) $raw_settings['autoreply_message'] );
		}

		if ( array_key_exists( 'enable_autoreply', $raw_settings ) ) {
			$settings['enable_autoreply'] = ! empty( $raw_settings['enable_autoreply'] ) ? '1' : '';
		}

		return $settings;
	}
}
