<?php
/**
 * NeccoSpeak — System prompt composer.
 *
 * Turns the admin-configured system prompt (and, from Phase 3 onward, any
 * additional sections registered via the 'neccospeak_prompt_sections' filter)
 * into the final byte-budgeted string sent to OpenAI — for both Chat
 * Completions ('text' mode) and Realtime ('realtime' mode).
 *
 * Also owns the UTF-8-safe byte-truncation helpers used across the plugin
 * wherever a string must be clamped to a byte budget without mbstring.
 *
 * Phase 3 (design rev.2 §5) adds the G11/G12/G13 prompt-builder sections
 * (role / rules / persona / goals / company / faq), the FAQ stepped-fit
 * helper, and the industry starter-preset data — all owned by impl-composer.
 *
 * Owner: impl-core / impl-composer (Phase 3).
 * Public contract (used by other files):
 *   neccospeak_strcut( $str, $max_bytes )
 *   neccospeak_strcut_fallback( $str, $max_bytes )
 *   neccospeak_normalize_prompt_mode( $mode )
 *   neccospeak_prompt_sections( $opts, $mode )
 *   neccospeak_compose_system_prompt_ex( $opts, $mode )
 *   neccospeak_compose_system_prompt( $opts, $mode = 'text' )
 *   neccospeak_prompt_faq_fit( $opts, $mode )
 *   neccospeak_prompt_presets()
 *   neccospeak_prompt_now_parts() (0.3.0-dev.60)
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/**
 * Byte budget for 'text' mode (Chat Completions) system prompts. 'realtime'
 * mode instead uses NECCOSPEAK_REALTIME_INSTRUCTIONS_CAP (defined in
 * includes/rest.php, value 8000 — confirmed by design rev.2 §0.2.5).
 *
 * NOTE: this specific value is this file's own reasonable default. The
 * design rev.2 excerpt that scoped this implementation task did not include
 * the original §4/§7 sections that would have fixed NECCOSPEAK_PROMPT_BUDGET_TEXT
 * explicitly (only the realtime cap was confirmed in §0.2.5's table). Flagged
 * here for PM / other-agent confirmation; override before this file loads if
 * a different value is intended — the guard below leaves that possible.
 */
if ( ! defined( 'NECCOSPEAK_PROMPT_BUDGET_TEXT' ) ) {
	define( 'NECCOSPEAK_PROMPT_BUDGET_TEXT', 24000 );
}

/**
 * Maximum bytes the 'faq' section itself may occupy within the overall
 * prompt budget, per mode (design rev.2 §5.4 / C7). These are section-level
 * caps used by neccospeak_prompt_faq_fit() to decide how many stored FAQ
 * rows (out of up to NECCOSPEAK_FAQ_MAX_ITEMS / NECCOSPEAK_FAQ_MAX_BYTES,
 * defined in includes/settings.php) actually get rendered — independent of,
 * and tighter than, the storage-side limits.
 */
if ( ! defined( 'NECCOSPEAK_FAQ_SECTION_BYTES_TEXT' ) ) {
	define( 'NECCOSPEAK_FAQ_SECTION_BYTES_TEXT', 9000 );
}
if ( ! defined( 'NECCOSPEAK_FAQ_SECTION_BYTES_REALTIME' ) ) {
	define( 'NECCOSPEAK_FAQ_SECTION_BYTES_REALTIME', 2500 );
}

/**
 * Byte-safely truncate a string to at most $max_bytes bytes, never splitting
 * a multi-byte UTF-8 character (design rev.2 §0.2.2 — the previous code used
 * substr(), which can cut a multi-byte character in half and hand OpenAI
 * invalid UTF-8).
 *
 * @param string $str       Input string.
 * @param int    $max_bytes Maximum number of bytes to keep (must be > 0).
 * @return string Truncated string, or '' for invalid input.
 */
function neccospeak_strcut( $str, $max_bytes ) {
	if ( ! is_string( $str ) || $max_bytes <= 0 ) {
		return '';
	}
	if ( strlen( $str ) <= $max_bytes ) {
		return $str;
	}
	if ( function_exists( 'mb_strcut' ) ) {
		return mb_strcut( $str, 0, $max_bytes, 'UTF-8' );
	}
	return neccospeak_strcut_fallback( $str, $max_bytes );
}

/**
 * UTF-8-safe byte cut without mbstring (design rev.2 §0.2.2, review issue
 * #16). Exposed separately (rather than as a branch inline in
 * neccospeak_strcut()) so tests can exercise the no-mbstring path directly
 * without disabling the mbstring extension in the test process itself.
 *
 * Truncates to $max_bytes bytes, then walks backward at most 3 bytes to find
 * the lead byte of the trailing UTF-8 sequence; if that sequence turned out
 * to be cut mid-character (or is not a valid lead byte at all), the whole
 * incomplete sequence is dropped rather than kept as invalid UTF-8.
 *
 * @param string $str       Input string.
 * @param int    $max_bytes Maximum number of bytes to keep (must be > 0).
 * @return string Truncated string, or '' for invalid input.
 */
function neccospeak_strcut_fallback( $str, $max_bytes ) {
	if ( ! is_string( $str ) || $max_bytes <= 0 ) {
		return '';
	}
	if ( strlen( $str ) <= $max_bytes ) {
		return $str;
	}

	$cut = substr( $str, 0, $max_bytes );
	$len = strlen( $cut );

	// UTF-8 continuation bytes match 10xxxxxx (0x80-0xBF). Walk back at most
	// 3 of them (the longest sequence, 4 bytes, has 3 continuation bytes) to
	// find the lead byte of the (possibly incomplete) trailing character.
	$back  = 0;
	$found = false;
	while ( $back < $len && $back < 4 ) {
		$byte = ord( $cut[ $len - 1 - $back ] );
		if ( ( $byte & 0xC0 ) !== 0x80 ) {
			$found = true;
			break;
		}
		++$back;
	}

	if ( ! $found ) {
		// Either the tail is nothing but continuation-byte noise, or we
		// scanned the whole (short) string without finding a lead byte.
		// Neither can form a valid character; drop the scanned suffix.
		return substr( $cut, 0, $len - $back );
	}

	$lead_pos = $len - 1 - $back;
	$lead     = ord( $cut[ $lead_pos ] );

	if ( ( $lead & 0x80 ) === 0x00 ) {
		$needed = 0; // ASCII byte: always a complete character on its own.
	} elseif ( ( $lead & 0xE0 ) === 0xC0 ) {
		$needed = 1; // 2-byte sequence.
	} elseif ( ( $lead & 0xF0 ) === 0xE0 ) {
		$needed = 2; // 3-byte sequence.
	} elseif ( ( $lead & 0xF8 ) === 0xF0 ) {
		$needed = 3; // 4-byte sequence.
	} else {
		$needed = -1; // Not a valid UTF-8 lead byte.
	}

	if ( $needed < 0 || $back < $needed ) {
		// Invalid lead, or the trailing sequence was cut mid-character: drop
		// the whole thing rather than emit invalid/partial UTF-8.
		return substr( $cut, 0, $lead_pos );
	}

	return $cut;
}

/**
 * Normalize a prompt mode value. Anything other than 'realtime' is treated
 * as 'text' (design rev.2 §13.3 test-prompt.php case 7).
 *
 * @param mixed $mode Raw mode value.
 * @return string 'text' | 'realtime'.
 */
function neccospeak_normalize_prompt_mode( $mode ) {
	return 'realtime' === $mode ? 'realtime' : 'text';
}

/* -------------------------------------------------------------------------
 * G11/G12/G13 prompt-builder section helpers (design rev.2 §5.2).
 *
 * These are internal helpers for neccospeak_prompt_sections() below, not
 * part of the file's public contract (§11.1 only lists
 * neccospeak_prompt_sections() / neccospeak_prompt_faq_fit() /
 * neccospeak_prompt_presets()). Every user-facing string here is wrapped in
 * __() so the composed prompt is translatable.
 * ---------------------------------------------------------------------- */

/**
 * Split a raw textarea-style value into a list of non-empty, trimmed lines,
 * on any run of line-break characters (design rev.2 §5.2, `rules` / `goals`
 * line-splitting rule: `preg_split('/\R+/u', $raw)` -> `trim()` -> drop
 * empty lines).
 *
 * @param mixed $raw Raw option value (expected string; anything else -> no lines).
 * @return string[] Non-empty trimmed lines, in original order.
 */
function neccospeak_prompt_lines( $raw ) {
	$raw = is_string( $raw ) ? $raw : '';
	if ( '' === trim( $raw ) ) {
		return array();
	}
	$parts = preg_split( '/\R+/u', $raw );
	$lines = array();
	foreach ( (array) $parts as $part ) {
		$line = trim( (string) $part );
		if ( '' !== $line ) {
			$lines[] = $line;
		}
	}
	return $lines;
}

/**
 * `prompt_unknown_behavior` -> the "When you do not know the answer: …"
 * sentence (design rev.2 §5.2 `rules` table). Empty string means the whole
 * line is omitted.
 *
 * @param string $value Raw `prompt_unknown_behavior` option value.
 * @return string
 */
function neccospeak_prompt_unknown_phrase( $value ) {
	switch ( $value ) {
		case 'admit':
			return __( 'Say plainly that you do not know, and offer to help the visitor rephrase or narrow the question. Never invent an answer.', 'neccospeak' );
		case 'contact':
			return __( 'Say that you cannot answer this one and point the visitor to the site\'s contact channel. Never invent an answer.', 'neccospeak' );
		default:
			return '';
	}
}

/**
 * `prompt_tone` -> the "Tone: …" sentence (design rev.2 §5.2 `persona`
 * table). Empty string ('default' or anything unrecognized) omits the line.
 *
 * @param string $value Raw `prompt_tone` option value.
 * @return string
 */
function neccospeak_prompt_tone_phrase( $value ) {
	$map = array(
		'neutral'      => __( 'Neutral and matter-of-fact.', 'neccospeak' ),
		'friendly'     => __( 'Friendly and approachable — warm, but not overly casual.', 'neccospeak' ),
		'formal'       => __( 'Formal and polite; use respectful language throughout.', 'neccospeak' ),
		'casual'       => __( 'Casual and conversational, as if talking to a friend.', 'neccospeak' ),
		'professional' => __( 'Professional and businesslike; precise and efficient.', 'neccospeak' ),
		'warm'         => __( 'Warm and empathetic; acknowledge how the visitor feels before answering.', 'neccospeak' ),
	);
	return isset( $map[ $value ] ) ? $map[ $value ] : '';
}

/**
 * `prompt_persona_template` (+ `prompt_persona_custom` for the 'custom'
 * case) -> the "Persona: …" sentence (design rev.2 §5.2 `persona` table).
 * Empty string omits the line.
 *
 * @param string $value  Raw `prompt_persona_template` option value.
 * @param string $custom Raw `prompt_persona_custom` option value (used only
 *                        when $value === 'custom').
 * @return string
 */
function neccospeak_prompt_persona_phrase( $value, $custom ) {
	switch ( $value ) {
		case 'assistant':
			return __( 'You are a calm, reliable support assistant.', 'neccospeak' );
		case 'concierge':
			return __( 'You are a hospitable concierge who anticipates what the visitor needs next.', 'neccospeak' );
		case 'advisor':
			return __( 'You are a knowledgeable advisor who explains the trade-offs before recommending anything.', 'neccospeak' );
		case 'teacher':
			return __( 'You are a patient teacher who checks understanding before moving on.', 'neccospeak' );
		case 'custom':
			$custom = is_string( $custom ) ? trim( $custom ) : '';
			return $custom;
		default:
			return '';
	}
}

/**
 * `prompt_styles` -> the "Answer style: …" sentence body (design rev.2 §5.2
 * `persona` table), joining the phrases for the selected styles with "; " in
 * the canonical order concise -> bullets -> examples -> steps regardless of
 * the input array's order. Unknown values are ignored (defensive: this
 * function must stay safe even if the stored option holds unexpected data).
 * Empty string (no recognized style selected) omits the line.
 *
 * @param mixed $styles Raw `prompt_styles` option value (expected string[]).
 * @return string
 */
function neccospeak_prompt_style_phrase( $styles ) {
	$map = array(
		'concise'  => __( 'keep answers short — a few sentences unless more detail is requested', 'neccospeak' ),
		'bullets'  => __( 'use bullet lists when presenting several items', 'neccospeak' ),
		'examples' => __( 'include a concrete example whenever it aids understanding', 'neccospeak' ),
		'steps'    => __( 'present procedures as numbered steps, in order', 'neccospeak' ),
	);
	$styles = is_array( $styles ) ? $styles : array();
	$parts  = array();
	foreach ( array( 'concise', 'bullets', 'examples', 'steps' ) as $key ) {
		if ( in_array( $key, $styles, true ) ) {
			$parts[] = $map[ $key ];
		}
	}
	return implode( '; ', $parts );
}

/**
 * `prompt_verbosity` -> the "Level of detail: …" sentence (design rev.2
 * §5.2 `persona` table). Empty string ('default' or anything unrecognized)
 * omits the line.
 *
 * @param string $value Raw `prompt_verbosity` option value.
 * @return string
 */
function neccospeak_prompt_verbosity_phrase( $value ) {
	$map = array(
		'brief'    => __( 'Aim for about 1-3 sentences per answer.', 'neccospeak' ),
		'balanced' => __( 'Aim for about 3-6 sentences per answer; add detail only when it is needed.', 'neccospeak' ),
		'detailed' => __( 'Give thorough answers, including background and caveats when they are relevant.', 'neccospeak' ),
	);
	return isset( $map[ $value ] ) ? $map[ $value ] : '';
}

/**
 * The 'faq' section's fixed heading + instruction line, shared verbatim by
 * neccospeak_prompt_faq_fit() (byte-budget bookkeeping) and
 * neccospeak_prompt_sections() (actual rendering) so the two can never
 * disagree about how many bytes the non-Q/A part of the section costs.
 *
 * @return string
 */
function neccospeak_prompt_faq_intro() {
	return "## " . __( 'Frequently asked questions', 'neccospeak' ) . "\n"
		. __( "Use these answers when a visitor's question matches. If a question is close but not identical, adapt the answer rather than inventing a new one.", 'neccospeak' )
		. "\n\n";
}

/**
 * The stored `prompt_faq` rows, defensively filtered down to the ones that
 * are actually usable: array rows with both a `q` and an `a` key that are
 * non-empty after casting to string and trimming (design rev.2 §5.4 step
 * 1-2, review C12). Malformed shapes (a string option, a list of scalars, a
 * row missing `a`, …) are silently skipped — never a PHP warning.
 *
 * Shared by neccospeak_prompt_faq_fit() (counts / measures) and
 * neccospeak_prompt_sections() (renders the first `used` of these).
 *
 * @param array $opts Plugin options.
 * @return array<int,array{q:string,a:string}>
 */
function neccospeak_prompt_faq_valid_rows( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$rows = ( isset( $opts['prompt_faq'] ) && is_array( $opts['prompt_faq'] ) ) ? $opts['prompt_faq'] : array();

	$valid = array();
	foreach ( $rows as $row ) {
		if ( ! is_array( $row ) || ! isset( $row['q'], $row['a'] ) ) {
			continue;
		}
		$q = trim( (string) $row['q'] );
		$a = trim( (string) $row['a'] );
		if ( '' === $q || '' === $a ) {
			continue;
		}
		$valid[] = array(
			'q' => $q,
			'a' => $a,
		);
	}
	return $valid;
}

/**
 * How many stored FAQ entries actually fit the FAQ section in $mode (design
 * rev.2 §5.4, C7). Pure and side-effect free: neccospeak_prompt_sections()
 * uses it to decide how many Q/A pairs to render, and
 * includes/admin-tabs.php uses the same function for the "N of M entries
 * fit" preview notice (§12.5) — sharing one function is what keeps the
 * preview and the real composed prompt from ever diverging.
 *
 * Entries are taken from the front of the valid-rows list; the FIRST entry
 * is always kept even if it alone exceeds the section's byte cap (the
 * section is then over budget for the section-level cap, but the ordinary
 * evict / hard-cut path in neccospeak_compose_system_prompt_ex() still
 * enforces the overall prompt budget on top of this).
 *
 * @param array  $opts Plugin options.
 * @param string $mode 'text' | 'realtime' (normalized; invalid values become 'text').
 * @return array{total:int,used:int,bytes:int}
 */
function neccospeak_prompt_faq_fit( $opts, $mode ) {
	$mode = neccospeak_normalize_prompt_mode( $mode );
	$opts = is_array( $opts ) ? $opts : array();

	$rows  = neccospeak_prompt_faq_valid_rows( $opts );
	$total = count( $rows );

	if ( 0 === $total ) {
		return array(
			'total' => 0,
			'used'  => 0,
			'bytes' => 0,
		);
	}

	$cap   = ( 'realtime' === $mode ) ? NECCOSPEAK_FAQ_SECTION_BYTES_REALTIME : NECCOSPEAK_FAQ_SECTION_BYTES_TEXT;
	$bytes = strlen( neccospeak_prompt_faq_intro() );
	$used  = 0;

	foreach ( $rows as $row ) {
		$entry = strlen( 'Q: ' . $row['q'] . "\n" . 'A: ' . $row['a'] );
		$sep   = ( $used > 0 ) ? 2 : 0; // "\n\n" between pairs, not before the first.
		$add   = $entry + $sep;

		if ( $bytes + $add > $cap ) {
			if ( 0 === $used ) {
				// The very first entry alone overflows the cap: keep it
				// anyway (design rev.2 §5.4 step 5) rather than emitting an
				// empty FAQ section.
				$bytes += $add;
				$used   = 1;
			}
			break;
		}

		$bytes += $add;
		++$used;
	}

	return array(
		'total' => $total,
		'used'  => $used,
		'bytes' => $bytes,
	);
}

/**
 * "Right now" in the SITE's timezone, as the parts the current-datetime
 * prompt section renders (0.3.0-dev.60).
 *
 * The weekday is deliberately produced from DateTimeImmutable::format('l'),
 * never wp_date()'s translated day names — the recipient is the model, and a
 * localized weekday would confuse a non-Japanese-speaking model more than it
 * helps (the same call the removed `current_datetime` tool made; design
 * phase6 §5.2「曜日は英語固定」).
 *
 * Every WP call is function_exists()-guarded like includes/widget-frame.php's
 * own locale block: a partial bootstrap must never fatal the composer.
 *
 * @return array{date:string,time:string,weekday:string,zone:string}
 */
function neccospeak_prompt_now_parts() {
	$tz = null;
	if ( function_exists( 'wp_timezone' ) ) {
		// WP core's wp_timezone() is, at bottom, `new DateTimeZone( wp_timezone_string() )`
		// — if the site's stored timezone_string is an identifier the running
		// PHP's tzdata doesn't know (DB migrated from a host with newer/older
		// tzdata, or a hand-edited option row), that constructor throws, and
		// wp_timezone() does not catch it. This function's own docblock promises
		// "must never fatal the composer", so this call gets the same try/catch
		// treatment as the wp_timezone_string() branch below (review F1: without
		// this, a broken timezone_string 500s every /chat, /realtime-token, and
		// admin prompt-preview request).
		try {
			$tz = wp_timezone();
		} catch ( \Exception $e ) {
			$tz = null;
		}
	} elseif ( function_exists( 'wp_timezone_string' ) ) {
		$name = (string) wp_timezone_string();
		try {
			$tz = new DateTimeZone( '' !== $name ? $name : 'UTC' );
		} catch ( \Exception $e ) {
			$tz = null;
		}
	}
	if ( ! $tz instanceof DateTimeZone ) {
		$tz = new DateTimeZone( 'UTC' );
	}

	$now  = new DateTimeImmutable( 'now', $tz );
	$name = (string) $tz->getName();
	// getName() is either an identifier ('Asia/Tokyo'), the bare string 'UTC',
	// or a bare offset ('+09:00') when the site is configured with a UTC
	// offset instead of a city; never print an offset twice. Implementer note
	// (0.3.0-dev.60): 'UTC' is deliberately its own branch, not folded into
	// the identifier branch below — DateTimeZone('UTC')->getName() is neither
	// '' nor '+'/'-'-prefixed, so without this it would fall through to
	// "UTC UTC+00:00" (name + ' UTC' + offset), which is exactly the
	// double-print this function exists to prevent; caught by this file's own
	// test-prompt.php D4.
	if ( '' === $name || 'UTC' === $name ) {
		$zone = 'UTC+00:00';
	} elseif ( '+' === $name[0] || '-' === $name[0] ) {
		$zone = 'UTC' . $name;
	} else {
		$zone = $name . ' UTC' . $now->format( 'P' );
	}

	return array(
		'date'    => $now->format( 'Y-m-d' ),
		'time'    => $now->format( 'H:i' ),
		'weekday' => $now->format( 'l' ),
		'zone'    => $zone,
	);
}

/**
 * Collect the system prompt's sections for $mode, budget-agnostic.
 *
 * Phase 1 ships a single required 'base' section (the admin's configured
 * system_prompt, verbatim). The 'neccospeak_prompt_sections' filter is the
 * extension point Phase 3's prompt-builder sections plug into (design rev.2
 * Appendix B: "§4.3 のセクションレジストリが受け皿"). Each section a filter
 * callback adds should look like:
 *
 *   array(
 *       'key'      => string  Stable identifier, unique per section.
 *       'label'    => string  Human-readable label (admin preview UI).
 *       'content'  => string  Raw text. Required; sections with '' content
 *                              are dropped silently.
 *       'priority' => int     Join order AND eviction order (default 100).
 *                              Sections join low-to-high priority; when the
 *                              byte budget is exceeded, non-required sections
 *                              are dropped starting from the HIGHEST priority
 *                              first.
 *       'required' => bool    When true, never dropped for budget (only a
 *                              final hard byte-cut can shorten the composed
 *                              text). Default false.
 *   )
 *
 * @param array  $opts Plugin options (reads key 'system_prompt').
 * @param string $mode 'text' | 'realtime' (normalized; invalid values become 'text').
 * @return array[] List of normalized sections (key, label, content, bytes,
 *                  priority, required). Sections with empty content are omitted.
 */
function neccospeak_prompt_sections( $opts, $mode ) {
	$mode = neccospeak_normalize_prompt_mode( $mode );
	$opts = is_array( $opts ) ? $opts : array();

	$sections = array();

	$base = isset( $opts['system_prompt'] ) ? (string) $opts['system_prompt'] : '';
	if ( '' !== trim( $base ) ) {
		$sections[] = array(
			'key'      => 'base',
			// Design rev.2 C10: 'Base system prompt' -> 'Base instructions'
			// (label only; key/priority/required and the absence of a '## '
			// heading in content are all unchanged per INV-2).
			'label'    => __( 'Base instructions', 'neccospeak' ),
			'content'  => $base,
			'priority' => 0,
			'required' => true,
		);
	}

	// Design rev.2 §5.1/§5.2: G11/G12/G13 prompt-builder sections, built
	// directly here (never via the filter below — INV-3) so
	// remove_all_filters( 'neccospeak_prompt_sections' ) in tests can never
	// make them disappear. Design rev.2 §0.2 C11: a section whose body would
	// consist of nothing but its own '## …' heading (every underlying field
	// empty/default) is not added at all — only sections with at least one
	// generated body line are pushed, so THESE sections never contribute
	// anything when every new option is at its default (review F3: the
	// composed-text-wide zero-emission/byte-equality invariant, INV-1, is a
	// separate, stronger claim than this — it no longer holds overall as of
	// 0.3.0-dev.60, since the unconditional 'current_datetime' section below
	// always adds content regardless of any option's value; see that
	// section's own comment and neccospeak_compose_system_prompt_ex()'s
	// docblock).

	$role = isset( $opts['prompt_role'] ) ? trim( (string) $opts['prompt_role'] ) : '';
	if ( '' !== $role ) {
		$role_label = __( 'Role', 'neccospeak' );
		$sections[] = array(
			'key'      => 'role',
			'label'    => $role_label,
			'content'  => "## {$role_label}\n{$role}",
			'priority' => 10,
			'required' => false,
		);
	}

	$rule_lines = array();

	$do_lines = neccospeak_prompt_lines( isset( $opts['prompt_do'] ) ? $opts['prompt_do'] : '' );
	if ( ! empty( $do_lines ) ) {
		$rule_lines[] = __( 'Always:', 'neccospeak' );
		foreach ( $do_lines as $line ) {
			$rule_lines[] = '- ' . $line;
		}
	}

	$dont_lines = neccospeak_prompt_lines( isset( $opts['prompt_dont'] ) ? $opts['prompt_dont'] : '' );
	if ( ! empty( $dont_lines ) ) {
		$rule_lines[] = __( 'Never:', 'neccospeak' );
		foreach ( $dont_lines as $line ) {
			$rule_lines[] = '- ' . $line;
		}
	}

	$unknown_value  = isset( $opts['prompt_unknown_behavior'] ) ? (string) $opts['prompt_unknown_behavior'] : 'none';
	$unknown_phrase = neccospeak_prompt_unknown_phrase( $unknown_value );
	if ( '' !== $unknown_phrase ) {
		/* translators: %s: sentence describing what to say when the answer is unknown. */
		$rule_lines[] = sprintf( __( 'When you do not know the answer: %s', 'neccospeak' ), $unknown_phrase );
	}

	$escalation = isset( $opts['prompt_escalation'] ) ? trim( (string) $opts['prompt_escalation'] ) : '';
	if ( '' !== $escalation ) {
		/* translators: %s: description of when to hand off to a human. */
		$rule_lines[] = sprintf( __( 'Escalate to a human when: %s', 'neccospeak' ), $escalation );
	}

	if ( ! empty( $rule_lines ) ) {
		$rules_label = __( 'Answer rules', 'neccospeak' );
		$sections[]  = array(
			'key'      => 'rules',
			'label'    => $rules_label,
			'content'  => "## {$rules_label}\n" . implode( "\n", $rule_lines ),
			'priority' => 20,
			'required' => false,
		);
	}

	$persona_lines = array();

	$tone = neccospeak_prompt_tone_phrase( isset( $opts['prompt_tone'] ) ? (string) $opts['prompt_tone'] : 'default' );
	if ( '' !== $tone ) {
		/* translators: %s: sentence describing the desired tone. */
		$persona_lines[] = sprintf( __( 'Tone: %s', 'neccospeak' ), $tone );
	}

	$persona_template = isset( $opts['prompt_persona_template'] ) ? (string) $opts['prompt_persona_template'] : 'none';
	$persona_custom   = isset( $opts['prompt_persona_custom'] ) ? (string) $opts['prompt_persona_custom'] : '';
	$persona          = neccospeak_prompt_persona_phrase( $persona_template, $persona_custom );
	if ( '' !== $persona ) {
		/* translators: %s: sentence describing the assistant's persona. */
		$persona_lines[] = sprintf( __( 'Persona: %s', 'neccospeak' ), $persona );
	}

	$style_phrase = neccospeak_prompt_style_phrase( isset( $opts['prompt_styles'] ) ? $opts['prompt_styles'] : array() );
	if ( '' !== $style_phrase ) {
		/* translators: %s: semicolon-separated list of answer-style instructions. */
		$persona_lines[] = sprintf( __( 'Answer style: %s', 'neccospeak' ), $style_phrase );
	}

	$verbosity = neccospeak_prompt_verbosity_phrase( isset( $opts['prompt_verbosity'] ) ? (string) $opts['prompt_verbosity'] : 'default' );
	if ( '' !== $verbosity ) {
		/* translators: %s: sentence describing the desired level of detail. */
		$persona_lines[] = sprintf( __( 'Level of detail: %s', 'neccospeak' ), $verbosity );
	}

	if ( ! empty( $persona_lines ) ) {
		$persona_label = __( 'Tone and style', 'neccospeak' );
		$sections[]    = array(
			'key'      => 'persona',
			'label'    => $persona_label,
			'content'  => "## {$persona_label}\n" . implode( "\n", $persona_lines ),
			'priority' => 30,
			'required' => false,
		);
	}

	$goal_lines = neccospeak_prompt_lines( isset( $opts['prompt_goals'] ) ? $opts['prompt_goals'] : '' );
	if ( ! empty( $goal_lines ) ) {
		$goals_body = array();
		foreach ( $goal_lines as $line ) {
			$goals_body[] = '- ' . $line;
		}
		$goals_label = __( 'Goals', 'neccospeak' );
		$sections[]  = array(
			'key'      => 'goals',
			'label'    => $goals_label,
			'content'  => "## {$goals_label}\n" . implode( "\n", $goals_body ),
			'priority' => 40,
			'required' => false,
		);
	}

	$company = isset( $opts['prompt_company'] ) ? trim( (string) $opts['prompt_company'] ) : '';
	if ( '' !== $company ) {
		$company_label = __( 'About this company / site', 'neccospeak' );
		$sections[]    = array(
			'key'      => 'company',
			'label'    => $company_label,
			'content'  => "## {$company_label}\n{$company}",
			'priority' => 50,
			'required' => false,
		);
	}

	$faq_fit = neccospeak_prompt_faq_fit( $opts, $mode );
	if ( $faq_fit['used'] > 0 ) {
		$faq_rows = array_slice( neccospeak_prompt_faq_valid_rows( $opts ), 0, $faq_fit['used'] );
		$pairs    = array();
		foreach ( $faq_rows as $row ) {
			$pairs[] = 'Q: ' . $row['q'] . "\n" . 'A: ' . $row['a'];
		}
		$sections[] = array(
			'key'      => 'faq',
			'label'    => __( 'Frequently asked questions', 'neccospeak' ),
			'content'  => neccospeak_prompt_faq_intro() . implode( "\n\n", $pairs ),
			'priority' => 90,
			'required' => false,
		);
	}

	// Reply-language policy (0.3.0-dev.24, realtime only). Voice Realtime
	// models anchor to the conversation's opening language (the greeting is
	// sent in the site language) and, unlike text Chat Completions, do NOT
	// reliably follow a mid-conversation language switch on their own — an
	// English speaker on a Japanese site kept getting Japanese audio back.
	// This standing instruction fixes that. Deliberately:
	//   - realtime-only: this section itself never appears in text mode, and
	//     text models already mirror the user's language (review F3: this is
	//     no longer "text mode keeps INV-1" — 0.3.0-dev.60's unconditional
	//     'current_datetime' section below separately supersedes INV-1 for
	//     text mode too; see that section's own comment);
	//   - priority -5 (before 'base'): the admin's own instructions come
	//     AFTER it, so an explicit admin language policy (e.g. "always
	//     answer in Japanese") still wins;
	//   - required: never evicted by the byte budget;
	//   - translated: on a Japanese-locale site the instruction itself is
	//     delivered in Japanese (ja.po), consistent with the rest of the
	//     prompt surface.
	if ( 'realtime' === $mode ) {
		$lang_follow_label = __( 'Reply language', 'neccospeak' );
		$sections[]        = array(
			'key'      => 'language_follow',
			'label'    => $lang_follow_label,
			'content'  => __( 'Always respond in the same language the user is currently using. If the user switches to another language mid-conversation, immediately switch your responses to that language.', 'neccospeak' ),
			'priority' => -5,
			'required' => true,
		);
	}

	// 0.3.0-dev.60: the current date and time, supplied to BOTH modes on every
	// request, regardless of any setting. It replaces the removed
	// `current_datetime` tool (product decision): a tool could only ever work
	// in text mode, needed two toggles to switch on, and switching it on
	// silently disabled SSE for every reply. Deliberately:
	//   - both modes: voice had NO way to read the clock at all before this;
	//   - required: never evicted by the byte budget;
	//   - priority -4 (right after language_follow, before 'base'): the front
	//     of the prompt is the only place the final hard cut cannot reach;
	//   - system-prompt-borne, never a conversation item: it is re-rendered
	//     on every /chat request, so a stale reading can never accumulate in
	//     the history the way a tool result or a spoken timestamp would.
	// This deliberately supersedes the design rev.2 §4.5 byte-for-byte
	// compat invariant (INV-1) for 'text' mode, exactly the way 0.3.0-dev.24
	// already superseded it for 'realtime' with language_follow above.
	$now_parts  = neccospeak_prompt_now_parts();
	$dt_label   = __( 'Current date and time', 'neccospeak' );
	$sections[] = array(
		'key'      => 'current_datetime',
		'label'    => $dt_label,
		'content'  => "## {$dt_label}\n" . sprintf(
			/* translators: 1: date, e.g. 2026-08-19. 2: 24-hour time, e.g. 14:30. 3: English weekday name, e.g. Wednesday. 4: the site timezone, e.g. "Asia/Tokyo UTC+09:00". %3$s and %4$s are machine-generated and stay in English — do not name a specific country's time here, the site timezone is whatever the site owner configured. */
			__( 'Right now it is %1$s %2$s (%3$s) in this site\'s timezone (%4$s). Treat this as the current date and time whenever the user asks about today, now, or a relative date; never answer from your training data.', 'neccospeak' ),
			$now_parts['date'],
			$now_parts['time'],
			$now_parts['weekday'],
			$now_parts['zone']
		),
		'priority' => -4,
		'required' => true,
	);

	/**
	 * Filters the system prompt's sections before budgeting.
	 *
	 * Phase 1 ships only the 'base' section above; this is the extension
	 * point for Phase 3's prompt-builder sections (design rev.2 Appendix B).
	 *
	 * @param array  $sections Current sections list (shape documented on
	 *                         neccospeak_prompt_sections() above).
	 * @param array  $opts     Plugin options.
	 * @param string $mode     'text' | 'realtime'.
	 */
	$sections = apply_filters( 'neccospeak_prompt_sections', $sections, $opts, $mode );

	$out = array();
	foreach ( (array) $sections as $section ) {
		if ( ! is_array( $section ) || ! isset( $section['key'], $section['content'] ) ) {
			continue;
		}
		$content = (string) $section['content'];
		if ( '' === $content ) {
			continue;
		}
		$out[] = array(
			'key'      => (string) $section['key'],
			'label'    => isset( $section['label'] ) ? (string) $section['label'] : (string) $section['key'],
			'content'  => $content,
			'bytes'    => strlen( $content ),
			'priority' => isset( $section['priority'] ) ? (int) $section['priority'] : 100,
			'required' => ! empty( $section['required'] ),
		);
	}

	return $out;
}

/**
 * Total byte size of $sections joined with "\n\n", without actually
 * joining — avoids repeated implode() calls inside the eviction loop in
 * neccospeak_compose_system_prompt_ex().
 *
 * @param array[] $sections Sections as returned by neccospeak_prompt_sections().
 * @return int
 */
function neccospeak_prompt_sections_bytes( $sections ) {
	$count = count( $sections );
	if ( 0 === $count ) {
		return 0;
	}
	$bytes = ( $count - 1 ) * 2; // "\n\n" between every pair of sections.
	foreach ( $sections as $section ) {
		$bytes += $section['bytes'];
	}
	return $bytes;
}

/**
 * Compose the final system prompt for $mode, applying the byte budget.
 *
 * Sections are joined in ascending priority order with a "\n\n" separator.
 * When the joined text exceeds the mode's budget, optional (non-required)
 * sections are dropped one at a time — highest priority first — until it
 * fits or none remain; if it is still over budget afterward (or only
 * required sections exist), the joined text is hard-cut with
 * neccospeak_strcut() (UTF-8-safe).
 *
 * Compatibility invariant (design rev.2 §4.5), historical: with the default
 * options and no 'neccospeak_prompt_sections' filters registered, the result
 * used to be exactly $opts['system_prompt'] (byte-for-byte) in both modes,
 * provided it fits the budget. This is deliberately no longer true in either
 * mode: 0.3.0-dev.24 added the required 'language_follow' section ahead of
 * 'base' for 'realtime', and 0.3.0-dev.60 added the required
 * 'current_datetime' section ahead of 'base' for BOTH modes (see that
 * section's own comment in neccospeak_prompt_sections() for why). With
 * default options and no filters, the result today is always at least
 * current_datetime's content joined with $opts['system_prompt'] (plus
 * language_follow's, in 'realtime').
 *
 * @param array  $opts Plugin options.
 * @param string $mode 'text' | 'realtime'.
 * @return array {
 *     @type string   text      Composed, budgeted prompt text.
 *     @type int      bytes     strlen( text ).
 *     @type int      budget    Byte budget used for this mode.
 *     @type bool     truncated True if any section was dropped and/or the
 *                              text was hard-cut.
 *     @type array[]  sections  Every collected section (including ones NOT
 *                              included in the result) with an added
 *                              'included' bool — for the admin preview table
 *                              (design rev.2 §11).
 *     @type string[] dropped   Keys of sections dropped for budget, in drop order.
 *     @type string   mode      Normalized mode.
 * }
 */
function neccospeak_compose_system_prompt_ex( $opts, $mode = 'text' ) {
	$mode   = neccospeak_normalize_prompt_mode( $mode );
	$budget = ( 'realtime' === $mode ) ? NECCOSPEAK_REALTIME_INSTRUCTIONS_CAP : NECCOSPEAK_PROMPT_BUDGET_TEXT;

	$sections = neccospeak_prompt_sections( $opts, $mode );

	usort(
		$sections,
		function ( $a, $b ) {
			return $a['priority'] <=> $b['priority'];
		}
	);

	$included = $sections;
	$dropped  = array();

	while ( neccospeak_prompt_sections_bytes( $included ) > $budget ) {
		$evict_index    = -1;
		$evict_priority = null;
		foreach ( $included as $i => $section ) {
			if ( $section['required'] ) {
				continue;
			}
			if ( null === $evict_priority || $section['priority'] > $evict_priority ) {
				$evict_priority = $section['priority'];
				$evict_index    = $i;
			}
		}
		if ( -1 === $evict_index ) {
			break; // Nothing optional left; only a hard cut can help now.
		}
		$dropped[] = $included[ $evict_index ]['key'];
		array_splice( $included, $evict_index, 1 );
	}

	$contents = array();
	foreach ( $included as $section ) {
		$contents[] = $section['content'];
	}
	$text = implode( "\n\n", $contents );

	$truncated = ! empty( $dropped );
	if ( strlen( $text ) > $budget ) {
		$truncated = true;
		$text      = neccospeak_strcut( $text, $budget );
	}

	$included_keys = array();
	foreach ( $included as $section ) {
		$included_keys[] = $section['key'];
	}

	$report = array();
	foreach ( $sections as $section ) {
		$report[] = array(
			'key'      => $section['key'],
			'label'    => $section['label'],
			'bytes'    => $section['bytes'],
			'priority' => $section['priority'],
			'required' => $section['required'],
			'included' => in_array( $section['key'], $included_keys, true ),
		);
	}

	return array(
		'text'      => $text,
		'bytes'     => strlen( $text ),
		'budget'    => $budget,
		'truncated' => $truncated,
		'sections'  => $report,
		'dropped'   => $dropped,
		'mode'      => $mode,
	);
}

/**
 * Convenience wrapper around neccospeak_compose_system_prompt_ex() that
 * returns just the composed text — what includes/rest.php sends to OpenAI as
 * the system message ('text' mode) / session instructions ('realtime' mode).
 *
 * @param array  $opts Plugin options.
 * @param string $mode 'text' | 'realtime'.
 * @return string Composed, budgeted prompt text.
 */
function neccospeak_compose_system_prompt( $opts, $mode = 'text' ) {
	$result = neccospeak_compose_system_prompt_ex( $opts, $mode );
	return $result['text'];
}

/**
 * Industry starter presets for the Prompt tab (G11, design rev.2 §6).
 *
 * Pure data — no side effects, nothing saved. Every value in a preset's
 * 'values' is a plain option value for one of the 13 G11/G12/G13 keys
 * (§2.1-2.3; 'system_prompt' is deliberately never included, so applying a
 * preset never clobbers an admin's existing free-text system prompt).
 * includes/admin-tabs.php (impl-admin) JSON-encodes ['values'] into a
 * data- attribute and assets/admin.js fills the matching form fields with
 * it; nothing here writes to the database.
 *
 * @return array<string,array{label:string,description:string,values:array<string,mixed>}>
 *         Keyed by preset slug, in display order:
 *         restaurant, clinic, ecommerce, professional (design rev.2 §11.1).
 */
function neccospeak_prompt_presets() {
	$presets = array();

	$presets['restaurant'] = array(
		'label'       => __( 'Restaurant / cafe', 'neccospeak' ),
		'description' => __( 'A starting point for restaurants and cafes: reservations, menu and dietary questions, opening hours.', 'neccospeak' ),
		'values'      => array(
			'prompt_role'             => __( 'You are the online assistant for a restaurant. You help visitors with the menu, opening hours, reservations, access, and dietary questions.', 'neccospeak' ),
			'prompt_goals'            => __( "Help visitors decide whether to visit, and make it easy to book\nAnswer menu, allergy and dietary questions accurately\nGive clear directions and parking information", 'neccospeak' ),
			'prompt_company'          => __( 'A small neighbourhood restaurant. Open 11:00-15:00 and 17:00-22:00, closed on Tuesdays. Reservations can be made online or by phone. Seats 30 people; private room for up to 8. Two-minute walk from the station, three parking spaces on site.', 'neccospeak' ),
			'prompt_tone'             => 'friendly',
			'prompt_styles'           => array( 'concise', 'bullets' ),
			'prompt_persona_template' => 'concierge',
			'prompt_persona_custom'   => '',
			'prompt_verbosity'        => 'brief',
			'prompt_do'               => __( "Mention opening hours and the closed day whenever a visit date is discussed\nSuggest booking when a party is larger than four people\nState clearly which dishes can be adjusted for allergies", 'neccospeak' ),
			'prompt_dont'             => __( "Never confirm a reservation yourself — always send the visitor to the booking page or phone number\nNever guarantee that a dish is free of a specific allergen; recommend confirming with the staff\nNever quote prices that are not listed on the site", 'neccospeak' ),
			'prompt_unknown_behavior' => 'contact',
			'prompt_escalation'       => __( 'Allergy or food-safety questions beyond what is written here, group bookings over 10 people, complaints about a past visit, or anything involving a refund.', 'neccospeak' ),
			'prompt_faq'              => array(
				array(
					'q' => __( 'Do you take reservations?', 'neccospeak' ),
					'a' => __( 'Yes. You can book online from the Reservations page, or call us during opening hours. Same-day bookings are phone only.', 'neccospeak' ),
				),
				array(
					'q' => __( 'Is there parking?', 'neccospeak' ),
					'a' => __( 'We have three spaces on site. They fill up quickly at lunchtime, so nearby coin parking is a good backup.', 'neccospeak' ),
				),
				array(
					'q' => __( 'Do you have vegetarian options?', 'neccospeak' ),
					'a' => __( 'Yes, several dishes are vegetarian and a few can be made vegan on request. Please tell the staff when you order so we can check the seasoning.', 'neccospeak' ),
				),
			),
		),
	);

	$presets['clinic'] = array(
		'label'       => __( 'Clinic / dental', 'neccospeak' ),
		'description' => __( 'A starting point for clinics and dental practices: appointments, departments and what to bring, without giving medical advice.', 'neccospeak' ),
		'values'      => array(
			'prompt_role'             => __( 'You are the reception assistant for a medical clinic website. You explain consultation hours, departments, what to bring, and how to book — but you never give medical advice.', 'neccospeak' ),
			'prompt_goals'            => __( "Make it easy for a patient to find the right department and book a visit\nExplain what to bring and what a first visit involves\nDirect anything clinical to a human professional", 'neccospeak' ),
			'prompt_company'          => __( 'A general clinic. Consultation hours 9:00-12:30 and 14:00-18:00 on weekdays, 9:00-13:00 on Saturdays; closed Sundays and public holidays. Departments: internal medicine, paediatrics, dermatology. First visits need an insurance card and any referral letter. Online booking is available for return visits.', 'neccospeak' ),
			'prompt_tone'             => 'formal',
			'prompt_styles'           => array( 'concise', 'steps' ),
			'prompt_persona_template' => 'assistant',
			'prompt_persona_custom'   => '',
			'prompt_verbosity'        => 'brief',
			'prompt_do'               => __( "State consultation hours and the department when a visit is discussed\nRemind first-time patients to bring their insurance card\nRecommend calling the clinic when symptoms are described", 'neccospeak' ),
			'prompt_dont'             => __( "Never diagnose, never suggest a diagnosis, and never comment on how serious a symptom might be\nNever recommend, name, or adjust any medication or dosage\nNever tell a visitor that they do or do not need to see a doctor", 'neccospeak' ),
			'prompt_unknown_behavior' => 'contact',
			'prompt_escalation'       => __( 'Any description of symptoms, pain, injury, medication, test results, pregnancy, or mental health; anything that sounds urgent; and any request for a medical certificate.', 'neccospeak' ),
			'prompt_faq'              => array(
				array(
					'q' => __( 'Do I need an appointment?', 'neccospeak' ),
					'a' => __( 'Walk-ins are accepted during consultation hours, but waiting times are shorter with a booking. Return visits can be booked online; first visits are by phone.', 'neccospeak' ),
				),
				array(
					'q' => __( 'What should I bring to my first visit?', 'neccospeak' ),
					'a' => __( 'Your insurance card, any referral letter, a list of medicines you are currently taking, and your medication record book if you have one.', 'neccospeak' ),
				),
				array(
					'q' => __( 'Are you open on Saturdays?', 'neccospeak' ),
					'a' => __( 'Yes, 9:00 to 13:00. We are closed on Sundays and public holidays.', 'neccospeak' ),
				),
			),
		),
	);

	$presets['ecommerce'] = array(
		'label'       => __( 'Online store', 'neccospeak' ),
		'description' => __( 'A starting point for online stores: shipping, returns and payment questions.', 'neccospeak' ),
		'values'      => array(
			'prompt_role'             => __( 'You are the customer support assistant for an online store. You help visitors find products, understand shipping and returns, and track what happens after an order.', 'neccospeak' ),
			'prompt_goals'            => __( "Help the visitor find the right product quickly\nAnswer shipping, payment and returns questions without ambiguity\nReduce the number of enquiries that need a human", 'neccospeak' ),
			'prompt_company'          => __( 'An online store shipping within the country. Orders placed before 14:00 on a weekday ship the same day; delivery normally takes 2-4 days. Free shipping over a set order value. Returns accepted within 14 days if unused and in the original packaging. Payment by card, bank transfer, and convenience-store payment.', 'neccospeak' ),
			'prompt_tone'             => 'professional',
			'prompt_styles'           => array( 'bullets', 'examples' ),
			'prompt_persona_template' => 'advisor',
			'prompt_persona_custom'   => '',
			'prompt_verbosity'        => 'balanced',
			'prompt_do'               => __( "Quote the shipping and returns rules exactly as written on the site\nAsk which product or order the visitor means when it is ambiguous\nPoint to the order-history page for anything order-specific", 'neccospeak' ),
			'prompt_dont'             => __( "Never ask for, repeat, or confirm a card number, security code, password, or full address\nNever promise a delivery date, a refund, or an exchange on the company's behalf\nNever invent stock levels, prices, or discount codes", 'neccospeak' ),
			'prompt_unknown_behavior' => 'contact',
			'prompt_escalation'       => __( 'A damaged or missing delivery, a refund or chargeback request, a change to an order that has already shipped, or anything involving payment details.', 'neccospeak' ),
			'prompt_faq'              => array(
				array(
					'q' => __( 'How long does delivery take?', 'neccospeak' ),
					'a' => __( 'Orders placed before 14:00 on a weekday ship the same day and usually arrive within 2 to 4 days. Remote areas can take a day or two longer.', 'neccospeak' ),
				),
				array(
					'q' => __( 'Can I return an item?', 'neccospeak' ),
					'a' => __( 'Yes, within 14 days of delivery, as long as the item is unused and in its original packaging. Start a return from the order history page.', 'neccospeak' ),
				),
				array(
					'q' => __( 'What payment methods do you accept?', 'neccospeak' ),
					'a' => __( 'Credit card, bank transfer, and convenience-store payment. Card payments are processed by our payment provider — we never see or store your card number.', 'neccospeak' ),
				),
			),
		),
	);

	$presets['professional'] = array(
		'label'       => __( 'Professional services', 'neccospeak' ),
		'description' => __( 'A starting point for professional services (tax, legal, administrative filings): booking a consultation without giving case-specific advice.', 'neccospeak' ),
		'values'      => array(
			'prompt_role'             => __( 'You are the first-contact assistant for a professional services firm (accounting, tax and administrative filings). You explain what the firm does, how fees and consultations work, and how to book an initial meeting.', 'neccospeak' ),
			'prompt_goals'            => __( "Help a prospective client understand whether the firm can help them\nExplain the consultation process and what to prepare\nBook an initial consultation rather than answering the case itself", 'neccospeak' ),
			'prompt_company'          => __( 'A professional services firm handling tax returns, company registration, payroll and business licence applications. The first consultation is 30 minutes and is free. Office hours 9:00-18:00 on weekdays. Consultations available in person or online. Fees are quoted after the initial consultation, based on scope.', 'neccospeak' ),
			'prompt_tone'             => 'formal',
			'prompt_styles'           => array( 'concise', 'steps' ),
			'prompt_persona_template' => 'advisor',
			'prompt_persona_custom'   => '',
			'prompt_verbosity'        => 'balanced',
			'prompt_do'               => __( "Explain what documents are typically needed for the service being asked about\nOffer the free initial consultation whenever a specific case is described\nState clearly that fees depend on scope and are quoted after the first meeting", 'neccospeak' ),
			'prompt_dont'             => __( "Never give tax, legal, or accounting advice about the visitor's specific situation\nNever state a deadline, tax rate, or legal requirement as fact — direct the visitor to the consultation instead\nNever quote a fee, and never estimate one", 'neccospeak' ),
			'prompt_unknown_behavior' => 'admit',
			'prompt_escalation'       => __( 'Any question about a specific filing, deadline, penalty, audit, contract or dispute; any request for a fee quote; and anything where the visitor describes their own financial or legal situation in detail.', 'neccospeak' ),
			'prompt_faq'              => array(
				array(
					'q' => __( 'Is the first consultation really free?', 'neccospeak' ),
					'a' => __( 'Yes. The first 30-minute consultation is free, in person or online, and there is no obligation to continue.', 'neccospeak' ),
				),
				array(
					'q' => __( 'How are your fees decided?', 'neccospeak' ),
					'a' => __( 'Fees depend on the scope of the work, so we quote after the initial consultation once we understand what is needed. There are no charges before you agree to a quote.', 'neccospeak' ),
				),
				array(
					'q' => __( 'What should I prepare for the first meeting?', 'neccospeak' ),
					'a' => __( 'A short summary of what you need help with, and any related documents you already have. Nothing else is required — we will tell you what else is needed afterwards.', 'neccospeak' ),
				),
			),
		),
	);

	return $presets;
}
