<?php
/**
 * NeccoSpeak — REST proxy.
 *
 * Public visitor endpoints that proxy to OpenAI. The OpenAI API key is read
 * from encrypted options here on the server and NEVER returned to the browser.
 *
 * Routes (namespace 'neccospeak/v1'):
 *   POST /chat            -> neccospeak_rest_chat            (Chat Completions relay, full text or SSE)
 *   POST /realtime-token  -> neccospeak_rest_realtime_token  (short-lived Realtime client secret)
 *   POST /events          -> neccospeak_rest_events          (Phase 5 G31 analytics batch)
 *   GET  /models          -> neccospeak_rest_models          (admin-only model discovery)
 *   GET|POST /ping        -> neccospeak_rest_ping            (public reachability probe, §10.4)
 *
 * 0.3.0-dev.42 撤去記録: 到達不能になっていた 3 ルートを登録・ハンドラごと
 * 削除した。
 *   GET  /diag            — 唯一の呼び出し元 assets/admin.js checkDiagC1() が
 *                           旧診断タブの #neccospeak-diag-c1 行にゲートされて
 *                           おり、その画面 (neccospeak_render_diagnostics_tab())
 *                           は agent-ui の menu callback 差し替えで到達不能。
 *   GET  /stream-selftest — 同上 (#neccospeak-diag-i1)。ヘルパ
 *                           neccospeak_sse_verdict() も道連れ。
 *   POST /usage-report    — 唯一の送信元 assets/js/widget-analytics.js を
 *                           レガシーウィジェットスタックごと削除。widget-frame
 *                           側は events / realtime-token しか叩かない。
 *                           ヘルパ neccospeak_normalize_voice_usage() も道連れ。
 *
 * Depends on includes/settings.php (loaded by neccospeak.php) for:
 *   neccospeak_get_options()
 *   neccospeak_option($key, $default = null)
 *   neccospeak_decrypt($enc)
 * Depends on includes/prompt.php (loaded by neccospeak.php) for:
 *   neccospeak_compose_system_prompt($opts, $mode)
 *
 * Phase 6 (docs/phase6-design.md §2.5, impl-chat's file): neccospeak_rest_chat()
 * additionally (a) runs a bounded function-calling tool loop (G16, §5.3), (b)
 * feeds the request-scoped page-context/RAG stores that includes/page-context.php
 * and includes/rag.php (if loaded) read from their 'neccospeak_prompt_sections'
 * filters (G15/G26, §2.8), and (c) can relay OpenAI's own SSE stream to the
 * browser (G29, §7.3). Every call into another agent's file is
 * function_exists()-guarded per the §1.3 cross-agent contract — none of
 * includes/page-context.php, includes/tools.php, includes/rag.php need to
 * exist for this file to work exactly as before (INV-1).
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/**
 * OpenAI endpoints (per Recon contract).
 */
if ( ! defined( 'NECCOSPEAK_OPENAI_CHAT_URL' ) ) {
	define( 'NECCOSPEAK_OPENAI_CHAT_URL', 'https://api.openai.com/v1/chat/completions' );
}
if ( ! defined( 'NECCOSPEAK_OPENAI_REALTIME_SECRETS_URL' ) ) {
	define( 'NECCOSPEAK_OPENAI_REALTIME_SECRETS_URL', 'https://api.openai.com/v1/realtime/client_secrets' );
}
if ( ! defined( 'NECCOSPEAK_OPENAI_MODELS_URL' ) ) {
	define( 'NECCOSPEAK_OPENAI_MODELS_URL', 'https://api.openai.com/v1/models' );
}
if ( ! defined( 'NECCOSPEAK_MODELS_CACHE_TTL' ) ) {
	define( 'NECCOSPEAK_MODELS_CACHE_TTL', 6 * HOUR_IN_SECONDS ); // Cache the model catalog for 6 hours.
}

/**
 * Input / safety limits.
 *
 * NECCOSPEAK_MAX_MSG_BYTES supersedes the old NECCOSPEAK_MAX_MSG_CHARS name:
 * the limit was always a byte boundary, not a character count, so the old
 * name was misleading (design rev.2 §0.2.5, review issue #13). The value is
 * unchanged (8000). NECCOSPEAK_MAX_MSG_CHARS is kept defined, as an alias,
 * for anything still referencing the old name; new code in this file uses
 * the _BYTES constant.
 */
if ( ! defined( 'NECCOSPEAK_MAX_HISTORY_MSGS' ) ) {
	define( 'NECCOSPEAK_MAX_HISTORY_MSGS', 30 );   // Recon: history clamped to 30 msgs.
}
if ( ! defined( 'NECCOSPEAK_MAX_HISTORY_BYTES' ) ) {
	define( 'NECCOSPEAK_MAX_HISTORY_BYTES', 80000 ); // Recon: ~80KB.
}
if ( ! defined( 'NECCOSPEAK_MAX_MSG_BYTES' ) ) {
	define( 'NECCOSPEAK_MAX_MSG_BYTES', 8000 );    // Per-message content cap (bytes).
}
if ( ! defined( 'NECCOSPEAK_MAX_MSG_CHARS' ) ) {
	define( 'NECCOSPEAK_MAX_MSG_CHARS', NECCOSPEAK_MAX_MSG_BYTES ); // Deprecated alias; see docblock above.
}
if ( ! defined( 'NECCOSPEAK_REALTIME_INSTRUCTIONS_CAP' ) ) {
	define( 'NECCOSPEAK_REALTIME_INSTRUCTIONS_CAP', 8000 ); // Recon: instructions clamp.
}
if ( ! defined( 'NECCOSPEAK_QUIRKS_CACHE_TTL' ) ) {
	define( 'NECCOSPEAK_QUIRKS_CACHE_TTL', 30 * DAY_IN_SECONDS ); // G17 learned model-parameter quirks.
}
if ( ! defined( 'NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS' ) ) {
	// G17: a model may reject more than one allow-listed param within the
	// same request (e.g. GPT-5-family models reject both max_tokens AND
	// temperature). The adaptive retry loop in neccospeak_rest_chat() /
	// neccospeak_rest_chat_stream_emit() keeps adjusting the payload — one
	// param per adaptation, never repeating a param already adjusted this
	// request (tracked via neccospeak_chat_g17_next_adaptation()'s
	// $adapted_params argument) — until it either succeeds or hits this cap.
	// The allow-list itself only has 3
	// members (temperature, max_tokens, max_completion_tokens), so 3 is a
	// hard ceiling that can never be exceeded by legitimate use; it exists
	// purely as a defensive stop against a pathological/adversarial upstream
	// that keeps returning a 400 (design: never let this loop spend the
	// user's time/money unbounded).
	define( 'NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS', 3 );
}

/**
 * G21 blocked-word filter (design rev.2 §7). NECCOSPEAK_BANNED_MASK carries
 * no letters/digits of its own, so a masked reply can never be re-detected as
 * containing a banned word on a later pass.
 */
if ( ! defined( 'NECCOSPEAK_BANNED_MAX_WORDS' ) ) {
	define( 'NECCOSPEAK_BANNED_MAX_WORDS', 200 );
}
if ( ! defined( 'NECCOSPEAK_BANNED_MIN_CHARS' ) ) {
	define( 'NECCOSPEAK_BANNED_MIN_CHARS', 2 ); // A 1-char entry would match almost every message.
}
if ( ! defined( 'NECCOSPEAK_BANNED_MASK' ) ) {
	define( 'NECCOSPEAK_BANNED_MASK', '●●' );
}

/**
 * G20 server-side PII masking (design rev.2 §8.1). Placeholders carry no
 * digits, so a masked message can never be re-detected as PII on a later pass.
 */
if ( ! defined( 'NECCOSPEAK_PII_MASK_EMAIL' ) ) {
	define( 'NECCOSPEAK_PII_MASK_EMAIL', '[email]' );
}
if ( ! defined( 'NECCOSPEAK_PII_MASK_PHONE' ) ) {
	define( 'NECCOSPEAK_PII_MASK_PHONE', '[phone]' );
}
if ( ! defined( 'NECCOSPEAK_PII_MASK_CARD' ) ) {
	define( 'NECCOSPEAK_PII_MASK_CARD', '[card number]' );
}

/**
 * Email pattern shared by neccospeak_pii_mask() / neccospeak_pii_detect().
 */
if ( ! defined( 'NECCOSPEAK_PII_EMAIL_PATTERN' ) ) {
	define( 'NECCOSPEAK_PII_EMAIL_PATTERN', '/[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}/u' );
}

/**
 * Numeric PII candidate pattern (design rev.2 §8.1, review C6): 8-18 digits
 * (ASCII or full-width), optionally separated by ONE of a wide set of dash /
 * space / parenthesis characters, optionally prefixed by a single +/＋.
 * Anchored by digit lookaround so a longer digit run is never partially
 * matched. The separator set includes the long-vowel-mark family (ー U+30FC,
 * ｰ U+FF70) plus several dash variants (‐ U+2010, — U+2014, ― U+2015, − U+2212,
 * ﹣ U+FE63, － U+FF0D) because Japanese IME input commonly produces these in
 * place of a plain hyphen (rev.1 missed this family entirely).
 *
 * This is a CANDIDATE pattern only — neccospeak_pii_numeric_candidate_class()
 * decides email/phone/card AFTER extraction (digit-count + Luhn), so widening
 * this separator set only widens what gets classified, never what gets masked
 * outright.
 */
if ( ! defined( 'NECCOSPEAK_PII_NUMERIC_PATTERN' ) ) {
	define(
		'NECCOSPEAK_PII_NUMERIC_PATTERN',
		'/(?<![0-9０-９])[\+\x{FF0B}]?[0-9０-９]'
		. '(?:[ \-\x{2010}\x{2014}\x{2015}\x{2212}\x{FE63}\x{FF0D}\x{30FC}\x{FF70}\x{3000}\(\)\x{FF08}\x{FF09}]?[0-9０-９]){7,17}'
		. '(?![0-9０-９])/u'
	);
}

/**
 * Phase 5 telemetry endpoints (design phase5 §6): /events (G31),
 * /usage-report (G3(b)). Values mirror includes/db.php's own
 * `if ( ! defined() )` guarded constants (design §3.0) of the same name —
 * both files may define them; whichever loads first wins, and the values
 * are kept in sync deliberately. Defining them here too means these routes
 * behave identically even in the (temporary, pre-Phase-5c) window where
 * includes/db.php has not been wired into neccospeak.php's require chain yet
 * — every db.php call in this file is function_exists()-guarded regardless
 * (design §1.3 cross-agent contract).
 */
if ( ! defined( 'NECCOSPEAK_EVENTS_BATCH_MAX' ) ) {
	define( 'NECCOSPEAK_EVENTS_BATCH_MAX', 20 ); // Max events accepted per /events request.
}
if ( ! defined( 'NECCOSPEAK_EVENTS_PER_SESSION' ) ) {
	define( 'NECCOSPEAK_EVENTS_PER_SESSION', 20 ); // design §6.3: assumed events/session for the daily-cap derivation.
}
if ( ! defined( 'NECCOSPEAK_EVENTS_MAX_BODY_BYTES' ) ) {
	define( 'NECCOSPEAK_EVENTS_MAX_BODY_BYTES', 8000 ); // design §6.3: oversized /events bodies get a 413.
}
if ( ! defined( 'NECCOSPEAK_VOICE_REPORT_SESSION_CAP' ) ) {
	define( 'NECCOSPEAK_VOICE_REPORT_SESSION_CAP', 400000 ); // design §6.4: per-request voice token clip.
}
if ( ! defined( 'NECCOSPEAK_VOICE_REPORT_TOKEN_SUM_MAX' ) ) {
	define( 'NECCOSPEAK_VOICE_REPORT_TOKEN_SUM_MAX', 2000000 ); // design §6.4: obvious-forgery rejection threshold (400).
}
if ( ! defined( 'NECCOSPEAK_VOICE_REPORT_TOKEN_FIELD_MAX' ) ) {
	define( 'NECCOSPEAK_VOICE_REPORT_TOKEN_FIELD_MAX', 5000000 ); // design §6.4: per-field clamp.
}

/**
 * Phase 6 G16 (design §5.1): the /chat tool-calling loop's own budget. Kept
 * distinct from includes/tools.php's NECCOSPEAK_TOOL_* constants (which own
 * the tool-execution side) — these three govern the upstream HTTP side that
 * only this file touches.
 */
if ( ! defined( 'NECCOSPEAK_CHAT_UPSTREAM_BUDGET' ) ) {
	define( 'NECCOSPEAK_CHAT_UPSTREAM_BUDGET', 45 ); // Seconds; deliberately under the ~60s php-fpm/nginx wall (design §5.1).
}
if ( ! defined( 'NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT' ) ) {
	define( 'NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT', 12 );
}
if ( ! defined( 'NECCOSPEAK_CHAT_UPSTREAM_MAX_TIMEOUT' ) ) {
	define( 'NECCOSPEAK_CHAT_UPSTREAM_MAX_TIMEOUT', 45 );
}

/**
 * Phase 6 G29 (design §7.3.3/§7.3.7): SSE relay constants.
 */
if ( ! defined( 'NECCOSPEAK_SSE_PREAMBLE_BYTES' ) ) {
	define( 'NECCOSPEAK_SSE_PREAMBLE_BYTES', 2048 ); // Padding comment so buffering proxies/FastCGI flush early.
}
if ( ! defined( 'NECCOSPEAK_SSE_HEARTBEAT_SEC' ) ) {
	define( 'NECCOSPEAK_SSE_HEARTBEAT_SEC', 15 );
}
if ( ! defined( 'NECCOSPEAK_SSE_MAX_SECONDS' ) ) {
	define( 'NECCOSPEAK_SSE_MAX_SECONDS', 50 ); // Same ~60s wall as NECCOSPEAK_CHAT_UPSTREAM_BUDGET (design §5.1).
}
if ( ! defined( 'NECCOSPEAK_SSE_HOLDBACK_MAX' ) ) {
	define( 'NECCOSPEAK_SSE_HOLDBACK_MAX', 64 ); // Bytes; caps neccospeak_sse_holdback_bytes()'s output.
}
if ( ! defined( 'NECCOSPEAK_SSE_SELFTEST_EVENTS' ) ) {
	define( 'NECCOSPEAK_SSE_SELFTEST_EVENTS', 5 );
}
if ( ! defined( 'NECCOSPEAK_SSE_SELFTEST_INTERVAL_MS' ) ) {
	define( 'NECCOSPEAK_SSE_SELFTEST_INTERVAL_MS', 200 );
}

/**
 * Phase 6 G16 (design §5.1): these three belong to includes/tools.php's own
 * constant table, but the /chat tool loop below (§5.3) reads them directly
 * when capping tool_calls-per-round / tool execution budgets — and per the
 * §1.3 cross-agent contract this file must behave correctly even if
 * includes/tools.php is not loaded. Mirrors the same "both files may define
 * it, whichever loads first wins, kept in sync deliberately" pattern this
 * file already uses for the Phase 5 telemetry constants above (NECCOSPEAK_EVENTS_BATCH_MAX
 * et al., which mirror includes/db.php's own guarded constants of the same
 * name).
 */
if ( ! defined( 'NECCOSPEAK_TOOL_MAX_CALLS_PER_ROUND' ) ) {
	define( 'NECCOSPEAK_TOOL_MAX_CALLS_PER_ROUND', 3 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_EXEC_BUDGET_MS' ) ) {
	define( 'NECCOSPEAK_TOOL_EXEC_BUDGET_MS', 4000 );
}
if ( ! defined( 'NECCOSPEAK_TOOL_EXEC_CALL_MS' ) ) {
	define( 'NECCOSPEAK_TOOL_EXEC_CALL_MS', 1500 );
}

/**
 * Register REST routes.
 *
 * Endpoints for the public widget (/chat, /realtime-token, /ping) keep
 * permission_callback as __return_true (open to visitors); we defend in
 * depth with same-origin enforcement + rate limiting (nonce is best-effort
 * only — see neccospeak_rest_guard() and design rev.2 §0.2.3). Admin-only
 * endpoints (/models, /diag) require manage_options AND a valid 'wp_rest'
 * nonce, enforced independently of the public routes' relaxed nonce handling.
 */
function neccospeak_register_rest_routes() {
	register_rest_route(
		'neccospeak/v1',
		'/chat',
		array(
			'methods'             => 'POST',
			'callback'            => 'neccospeak_rest_chat',
			'permission_callback' => '__return_true',
		)
	);

	register_rest_route(
		'neccospeak/v1',
		'/realtime-token',
		array(
			'methods'             => 'POST',
			'callback'            => 'neccospeak_rest_realtime_token',
			'permission_callback' => '__return_true',
		)
	);

	// Phase 5 telemetry endpoints (design phase5 §6): public visitor routes,
	// same permission_callback policy as /chat and /realtime-token above —
	// same-origin + rate limiting is enforced inside each callback via
	// neccospeak_rest_telemetry_preflight() / neccospeak_rest_guard().
	register_rest_route(
		'neccospeak/v1',
		'/events',
		array(
			'methods'             => 'POST',
			'callback'            => 'neccospeak_rest_events',
			'permission_callback' => '__return_true',
		)
	);

	// Admin-only model discovery. Unlike /chat and /realtime-token (public
	// visitor endpoints) this is gated by manage_options + a wp_rest nonce, so
	// site visitors can never reach it. Used by the settings page to populate
	// the model <datalist> from OpenAI's live catalog.
	register_rest_route(
		'neccospeak/v1',
		'/models',
		array(
			'methods'             => 'GET',
			'callback'            => 'neccospeak_rest_models',
			'permission_callback' => function () {
				return current_user_can( 'manage_options' );
			},
		)
	);

	// Public reachability probe (design rev.2 §10.4). Lets the Diagnostics tab
	// (C2 anonymous GET / C2b anonymous POST) and manual QA confirm the REST
	// namespace itself is reachable to anonymous visitors, independent of
	// /chat's rate limits or whether an OpenAI key is even configured. Never
	// touches OpenAI, the database, or rate limits.
	register_rest_route(
		'neccospeak/v1',
		'/ping',
		array(
			'methods'             => array( 'GET', 'POST' ),
			'callback'            => 'neccospeak_rest_ping',
			'permission_callback' => '__return_true',
		)
	);
}
add_action( 'rest_api_init', 'neccospeak_register_rest_routes' );

/**
 * Truncate a string for safe WP_DEBUG logging.
 *
 * Never log full upstream bodies (the realtime body can contain a token, and
 * chat bodies can be large). Keep only a short prefix.
 *
 * @param string $body Raw body/string.
 * @param int    $max  Max chars to keep (default 200).
 * @return string Truncated string.
 */
function neccospeak_log_trunc( $body, $max = 200 ) {
	$body = (string) $body;
	if ( strlen( $body ) > $max ) {
		return substr( $body, 0, $max ) . '…';
	}
	return $body;
}

/**
 * Public (nonce-relaxed) REST route allow-list for the fix in design rev.2
 * §0.2.3 (review issue #2).
 *
 * Admin-only routes (/models, /diag) are NEVER in this list — they keep
 * strict nonce enforcement via their own callbacks / permission_callback.
 *
 * @param string $path Route path, e.g. '/neccospeak/v1/chat'.
 * @return bool True when $path is one of our public widget routes.
 */
function neccospeak_is_public_rest_route( $path ) {
	if ( ! is_string( $path ) || '' === $path ) {
		return false;
	}
	$public = array(
		'/neccospeak/v1/chat',
		'/neccospeak/v1/realtime-token',
		'/neccospeak/v1/ping',
		// Phase 5 telemetry endpoints (design phase5 §6.1): same nonce-relaxation
		// treatment as the other public widget routes above — a stale cached
		// page's X-WP-Nonce must not 403 these either.
		//
		// 0.3.0-dev.42 撤去記録: 隣にあった '/neccospeak/v1/usage-report' は、
		// ルートごと削除したため取り除いた（唯一の送信元だった
		// assets/js/widget-analytics.js をレガシーウィジェットスタックごと
		// 削除。widget-frame 側は events / realtime-token しか叩かない）。
		'/neccospeak/v1/events',
	);
	foreach ( $public as $p ) {
		// Exact match, or $p followed by a '/' boundary — never a bare prefix
		// match. A plain strpos() === 0 prefix check would also (wrongly) match
		// an unregistered path like '/neccospeak/v1/chatty' against the
		// '/neccospeak/v1/chat' entry (defense-in-depth hardening; today's
		// registered routes don't collide like this, but the allow-list itself
		// should not depend on that staying true).
		if ( $path === $p || 0 === strpos( $path, $p . '/' ) ) {
			return true;
		}
	}
	return false;
}

/**
 * Resolve the current REST route path (e.g. '/neccospeak/v1/chat'), before
 * dispatch. Handles both the permalink form (/wp-json/...) and the plain
 * query-var form (?rest_route=...).
 *
 * $_POST['rest_route'] is checked BEFORE $_GET['rest_route'], matching core's
 * own WP::parse_request() (class-wp.php), which merges $_POST over $_GET so a
 * POSTed 'rest_route' wins when both are present. Checking $_GET first would
 * let 'POST /index.php?rest_route=/neccospeak/v1/chat' with a body containing
 * 'rest_route=/neccospeak/v1/models' report the admin-only path while core
 * actually dispatches to /chat — misapplying (or, for an admin-only route,
 * wrongly withholding) the public-route nonce relaxation (review issue:
 * $_GET-only path resolution could desync from the route WP actually
 * dispatches to).
 *
 * @return string Route path (leading slash, no trailing slash), or '' if it
 *                 cannot be determined.
 */
function neccospeak_current_rest_path() {
	if ( isset( $_POST['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only route introspection, not a state change.
		$route = sanitize_text_field( wp_unslash( $_POST['rest_route'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Missing
		return '' !== $route ? $route : '';
	}

	if ( isset( $_GET['rest_route'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route introspection, not a state change.
		$route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
		return '' !== $route ? $route : '';
	}

	$request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
	if ( '' === $request_uri ) {
		return '';
	}

	$path = (string) wp_parse_url( $request_uri, PHP_URL_PATH );
	if ( '' === $path ) {
		return '';
	}

	$prefix = rest_get_url_prefix(); // 'wp-json' by default, filterable.
	$needle = '/' . trim( $prefix, '/' ) . '/';
	$pos    = strpos( $path, $needle );
	if ( false === $pos ) {
		return '';
	}

	// -1 keeps the leading slash of the route itself.
	$route = substr( $path, $pos + strlen( $needle ) - 1 );

	return untrailingslashit( $route );
}

/**
 * Relax core's rest_cookie_check_errors() nonce rejection for our public
 * widget routes only (design rev.2 §0.2.3, review issue #2).
 *
 * Core's rest_cookie_check_errors() runs in the REST authentication phase —
 * before any permission_callback — and turns a PRESENT-but-stale X-WP-Nonce
 * into a hard 403, even for a visitor who is not logged in, whenever a
 * logged-in cookie happens to be present. Page caching can serve a visitor an
 * old page carrying a now-expired nonce, which is the actual root cause of
 * the "nonce 403" bug: a client-side-only fix does nothing for visitors
 * already holding a stale cached page until that page's cache entry expires.
 * We treat that one specific error as "anonymous request" instead, for our
 * public routes only; admin-only routes (/models, /diag) are excluded via
 * neccospeak_is_public_rest_route() and keep full nonce enforcement.
 *
 * @param WP_Error|null|true $errors Current REST auth error state.
 * @return WP_Error|null|true
 */
function neccospeak_relax_nonce_for_public_routes( $errors ) {
	if ( ! is_wp_error( $errors ) || 'rest_cookie_invalid_nonce' !== $errors->get_error_code() ) {
		return $errors;
	}

	if ( neccospeak_is_public_rest_route( neccospeak_current_rest_path() ) ) {
		return null; // Treat as anonymous instead of rejecting.
	}

	return $errors;
}
// Priority MUST run AFTER WordPress core's own rest_cookie_check_errors(),
// which core registers on 'rest_authentication_errors' at priority 100
// (wp-includes/default-filters.php). A lower priority (e.g. the previous 99)
// runs BEFORE core, so $errors is always still null/true when this filter
// sees it and rest_cookie_invalid_nonce can never be observed here — making
// this relaxation a complete no-op and leaving every visitor holding a stale
// cached page (old X-WP-Nonce + credentials:same-origin) stuck on 403 until
// the page cache entry expires (review: nonce-403 fix was a no-op, "high").
add_filter( 'rest_authentication_errors', 'neccospeak_relax_nonce_for_public_routes', PHP_INT_MAX );

/**
 * Shared security guard for the public widget endpoints.
 *
 * Public widget endpoints: permission_callback stays __return_true (visitors
 * are not logged in), so we defend in depth here —
 *   (a) nonce (best-effort only): core's own rest_cookie_check_errors()
 *       already enforces nonce validity for authenticated (cookie) requests;
 *       neccospeak_relax_nonce_for_public_routes() above relaxes that
 *       specifically for these public routes (design rev.2 §0.2.3). A
 *       present-but-invalid nonce reaching this guard is therefore never
 *       rejected here — it is only logged (WP_DEBUG only) and the request
 *       proceeds as anonymous.
 *   (b) same-origin (best-effort): the Origin (preferred) or Referer host MUST
 *       match the site host; if both are absent we reject. This stops typical
 *       in-browser cross-site calls, but is NOT a hard auth boundary — a
 *       non-browser client (e.g. curl) can forge these headers. It is one layer
 *       of defense-in-depth, not authentication.
 * The primary protection against budget abuse is the per-IP / global rate
 * limits below; same-origin is the complementary layer.
 *
 * @param WP_REST_Request $req Request.
 * @return true|WP_REST_Response True when allowed, 403 response when blocked.
 */
function neccospeak_rest_guard( $req ) {
	// (a) Nonce: best-effort only, never a hard rejection here (see docblock).
	$nonce = $req->get_header( 'X-WP-Nonce' );
	if ( ! empty( $nonce ) && ! wp_verify_nonce( $nonce, 'wp_rest' ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
		error_log( 'NeccoSpeak: REST request had an invalid nonce (treated as anonymous).' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
	}

	// (b) Same-origin enforcement via Origin (preferred) or Referer host.
	$site_host = wp_parse_url( home_url(), PHP_URL_HOST );
	$origin    = $req->get_header( 'Origin' );
	$req_host  = '';
	if ( ! empty( $origin ) ) {
		$req_host = (string) wp_parse_url( $origin, PHP_URL_HOST );
	} else {
		$referer = $req->get_header( 'Referer' );
		if ( ! empty( $referer ) ) {
			$req_host = (string) wp_parse_url( $referer, PHP_URL_HOST );
		}
	}

	if ( '' === $req_host || strtolower( $req_host ) !== strtolower( (string) $site_host ) ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak: REST request rejected (cross-origin / missing Origin & Referer).' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		return new WP_REST_Response(
			array( 'error' => __( 'Forbidden.', 'neccospeak' ) ),
			403
		);
	}

	return true;
}

/**
 * Resolve the visitor's client IP for rate limiting.
 *
 * @return string Sanitized IP-ish identifier (always non-empty).
 */
function neccospeak_client_ip() {
	$ip = '';
	if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
		$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
	}
	$ip = filter_var( $ip, FILTER_VALIDATE_IP );
	if ( false === $ip || '' === $ip ) {
		$ip = 'unknown';
	}
	return $ip;
}

/**
 * Which atomic counter backend rate limiting currently uses (design rev.2
 * §0.2.4 / G4 diagnostics D1). Cheap and side-effect-free — it only checks
 * capability, never performs a query — so on a non-MySQL $wpdb driver it may
 * optimistically report 'db-atomic' even though neccospeak_db_atomic_increment()
 * would fail per-call at runtime and fall back to the transient tier.
 *
 * @return string 'object-cache' | 'db-atomic' | 'transient'.
 */
function neccospeak_counter_backend() {
	if ( wp_using_ext_object_cache() ) {
		return 'object-cache';
	}
	global $wpdb;
	if ( isset( $wpdb ) && is_object( $wpdb ) && method_exists( $wpdb, 'query' ) ) {
		return 'db-atomic';
	}
	return 'transient';
}

/**
 * Atomically increment a per-window counter and return the new value.
 *
 * Tries, in order: a persistent object cache (atomic there), then a direct
 * SQL atomic upsert against wp_options, then a last-resort non-atomic
 * transient read/write (design rev.2 §0.2.4, review issue #10).
 *
 * @param string $name   Transient name (without the "_transient_" prefix).
 * @param int    $window Seconds until this counter's window may be reclaimed.
 * @param int    $amount Amount to add this call (default 1). Callers that need
 *                        to consume more than one unit per request (e.g. /events
 *                        charging its daily cap per accepted EVENT, not per
 *                        request — design §6.3, review "events cap counted
 *                        requests, not events") pass the real weight here so
 *                        the increment stays a single atomic operation instead
 *                        of a decide-then-loop pattern that could under-count
 *                        under concurrency.
 * @return int New count (>= $amount) after incrementing.
 */
function neccospeak_atomic_increment( $name, $window, $amount = 1 ) {
	$amount = max( 1, (int) $amount );

	if ( wp_using_ext_object_cache() ) {
		// wp_cache_add() only succeeds the first time (atomic "create if
		// absent"); wp_cache_incr() is atomic on a real (Redis/Memcached-backed)
		// persistent object cache drop-in.
		wp_cache_add( $name, 0, 'neccospeak', $window );
		$count = wp_cache_incr( $name, $amount, 'neccospeak' );
		if ( false !== $count ) {
			return (int) $count;
		}
		// Unexpected incr failure on a cache that claims to be persistent; fall
		// through to the DB tier below rather than silently under-counting.
	}

	$count = neccospeak_db_atomic_increment( $name, $window, $amount );
	if ( false !== $count ) {
		return $count;
	}

	return neccospeak_transient_increment_fallback( $name, $window, $amount );
}

/**
 * Atomically increment a counter stored directly in wp_options, bypassing the
 * options API's non-atomic read-then-write pattern (design rev.2 §0.2.4,
 * review issue #10 — the old get_transient()/set_transient() pair could
 * under-count concurrent requests). The row lives under the standard
 * "_transient_neccospeak_..." prefix so uninstall.php's existing LIKE-based
 * DELETE still removes it, and the per-option/'notoptions' caches are
 * invalidated immediately after each write. The row is always inserted with
 * autoload='no', so it is never part of the 'alloptions' cache and flushing
 * that cache on every increment is neither necessary nor done here (review:
 * doing so forced a full autoload re-query on every rate-limit check under a
 * persistent object cache — three times per /chat request).
 *
 * @param string $name   Transient name (without the "_transient_" prefix).
 * @param int    $window Seconds until the row becomes eligible for GC.
 * @param int    $amount Amount to add this call (default 1).
 * @return int|false New count after incrementing, or false on failure
 *                    (no usable $wpdb, or a non-MySQL driver that rejects the
 *                    query — callers must fall back on false).
 */
function neccospeak_db_atomic_increment( $name, $window, $amount = 1 ) {
	global $wpdb;
	if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! method_exists( $wpdb, 'query' ) ) {
		return false;
	}
	$amount = max( 1, (int) $amount );

	$option_name = '_transient_' . $name;

	// Atomic insert-or-increment via MySQL's LAST_INSERT_ID(expr) trick:
	// LAST_INSERT_ID(expr) sets the *connection's* last-insert-id to expr when
	// the ON DUPLICATE KEY UPDATE branch runs, so $wpdb->insert_id reliably
	// returns the counter's new value there in a single round trip with no
	// separate read step.
	//
	// The INSERT branch is different: wp_options.option_id is itself
	// AUTO_INCREMENT, and per MySQL's documented precedence, when a *genuine*
	// new row is inserted, mysql_insert_id() returns the row's own
	// auto-generated option_id — NOT the LAST_INSERT_ID(1) literal in the
	// VALUES list — even though that literal is present. Trusting insert_id
	// unconditionally on this branch would read back option_id (routinely in
	// the thousands/millions) as the "count", making $count <= $limit false
	// on the very first request of every new time bucket for every IP (review
	// issue: "high" — first request of each window always 503s).
	//
	// $wpdb->rows_affected is the reliable discriminator instead: MySQL
	// reports rows_affected = 1 for a plain INSERT and 2 for a row that an
	// ON DUPLICATE KEY UPDATE actually changed (WordPress's DB connection does
	// not set the CLIENT_FOUND_ROWS flag, so an unchanged-value UPDATE would
	// report 0 — never reachable here since we always increment). So: exactly
	// 1 row affected means this call created the bucket (count is $amount, the
	// value it was seeded with); anything else means the UPDATE branch ran,
	// and insert_id is trustworthy there.
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic increment; WP has no API for it (§0.2.4).
	$result = $wpdb->query(
		$wpdb->prepare(
			"INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, LAST_INSERT_ID(%d), 'no') ON DUPLICATE KEY UPDATE option_value = LAST_INSERT_ID(option_value + %d)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$wpdb->options} is the core table name constant, not user input; the only variable values are bound via %s/%d.
			$option_name,
			$amount,
			$amount
		)
	);

	if ( false === $result ) {
		return false;
	}

	$count = ( 1 === (int) $wpdb->rows_affected ) ? $amount : (int) $wpdb->insert_id;
	if ( $count <= 0 ) {
		// Defensive: never let a driver quirk (e.g. non-MySQL $wpdb) report a
		// bogus non-positive count as "allowed".
		return false;
	}

	// Row was inserted/updated with autoload='no', so it is never part of the
	// 'alloptions' cache — only the per-option and 'notoptions' caches need
	// invalidating here (review: unnecessary 'alloptions' flush on every
	// increment forced a full autoload re-query on every chat request under a
	// persistent object cache).
	wp_cache_delete( $option_name, 'options' );
	wp_cache_delete( 'notoptions', 'options' );

	// Best-effort companion timeout row so core's own expired-transient GC
	// (delete_expired_transients(), diagnostics A8) reclaims this row later.
	// Failure here never affects the counter's correctness, only cleanup.
	$timeout_name = '_transient_timeout_' . $name;
	// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Companion timeout row; see §0.2.4.
	$wpdb->query(
		$wpdb->prepare(
			"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$wpdb->options} is the core table name constant, not user input.
			$timeout_name,
			(string) ( time() + $window )
		)
	);
	wp_cache_delete( $timeout_name, 'options' );
	wp_cache_delete( 'notoptions', 'options' );

	return $count;
}

/**
 * Last-resort non-atomic counter (pre-fix behavior): read, then write back
 * count+1. Only reached when neither a persistent object cache nor direct SQL
 * is available. Under concurrency this can under-count (design rev.2 §0.2.4,
 * review issue #10) — Diagnostics D1 surfaces this as a warning when it is
 * the active backend.
 *
 * @param string $name   Transient name (without the "_transient_" prefix).
 * @param int    $window Seconds until the counter's window expires.
 * @param int    $amount Amount to add this call (default 1).
 * @return int New count (>= $amount) after incrementing.
 */
function neccospeak_transient_increment_fallback( $name, $window, $amount = 1 ) {
	$count  = (int) get_transient( $name );
	$count += max( 1, (int) $amount );
	set_transient( $name, $count, $window );
	return $count;
}

/**
 * Atomic fixed-window counter check.
 *
 * Increments the counter for the current window of $key/$window and returns
 * whether the caller is still within $limit (inclusive). The key is aligned
 * to a fixed time bucket (floor(time()/$window)) so incrementing is the ONLY
 * operation ever required — there is no separate "read the window state,
 * decide whether to reset it" step, which is exactly what made the pre-fix
 * transient-based counter non-atomic under concurrency (design rev.2 §0.2.4).
 *
 * @param string $key    Key namespacing the bucket (caller-supplied, no time suffix).
 * @param int    $limit  Inclusive max count allowed within the window.
 * @param int    $window Window length in seconds.
 * @param int    $amount Units this call consumes from the window (default 1).
 *                        A caller whose single request represents more than
 *                        one countable unit (e.g. /events, which may insert up
 *                        to NECCOSPEAK_EVENTS_BATCH_MAX rows per request —
 *                        design §6.3, review "daily cap counted requests, not
 *                        events") passes the real weight so the bucket is
 *                        consumed by the unit the cap is documented in, not by
 *                        request count.
 * @return bool True if allowed (count <= limit after incrementing).
 */
function neccospeak_atomic_window_allow( $key, $limit, $window, $amount = 1 ) {
	if ( $limit <= 0 ) {
		// Defensive: never treat a misconfigured cap as "unlimited".
		$limit = 1;
	}
	if ( $window <= 0 ) {
		$window = MINUTE_IN_SECONDS;
	}

	$bucket = (int) floor( time() / $window );
	$name   = 'neccospeak_' . $key . '_' . $bucket;

	$count = neccospeak_atomic_increment( $name, $window, $amount );

	return $count <= $limit;
}

/**
 * Per-IP fixed-window (60s) limiter for a given endpoint bucket.
 *
 * @param string $bucket Endpoint bucket name ('chat' | 'realtime') for key isolation.
 * @param int    $limit  Per-minute limit for this bucket.
 * @param int    $amount Units this call consumes (default 1). Phase 6 G16
 *                        (design §5.8): with tools enabled, /chat's preflight
 *                        passes 1 + max_rounds so the limit still bounds real
 *                        upstream calls, not just /chat arrivals.
 * @return bool True if allowed.
 */
function neccospeak_check_ip_rate_limit( $bucket, $limit, $amount = 1 ) {
	$ip  = neccospeak_client_ip();
	$key = 'rl_' . $bucket . '_' . md5( $ip );
	return neccospeak_atomic_window_allow( $key, (int) $limit, MINUTE_IN_SECONDS, $amount );
}

/**
 * Site-wide (global) fixed-window limiter for a given endpoint bucket/period.
 *
 * Uses a distinct key per bucket AND per period to avoid collisions between
 * /chat and /realtime-token and between hourly/daily windows.
 *
 * @param string $bucket Endpoint bucket name ('chat' | 'realtime').
 * @param string $period Period label ('hourly' | 'daily').
 * @param int    $limit  Limit for this bucket/period.
 * @param int    $window Window length in seconds.
 * @param int    $amount Units this call consumes (default 1); see
 *                        neccospeak_atomic_window_allow()'s docblock.
 * @return bool True if allowed.
 */
function neccospeak_check_global_rate_limit( $bucket, $period, $limit, $window, $amount = 1 ) {
	$key = 'grl_' . $bucket . '_' . $period;
	return neccospeak_atomic_window_allow( $key, (int) $limit, $window, $amount );
}

/**
 * Build a 503 "too many requests" response with a Retry-After header.
 *
 * @param int $retry_after Seconds the client should wait.
 * @return WP_REST_Response
 */
function neccospeak_rate_limited_response( $retry_after ) {
	$res = new WP_REST_Response(
		array( 'error' => __( 'Rate limit exceeded. Please slow down.', 'neccospeak' ) ),
		503
	);
	$res->header( 'Retry-After', (string) max( 1, (int) $retry_after ) );
	return $res;
}

/**
 * Apply the chat rate limits (per-IP minute + global hourly + global daily).
 *
 * @param int $amount Units this request consumes (default 1). Phase 6 G16
 *                     (design §5.8): neccospeak_rest_preflight() passes
 *                     1 + max_rounds when tools are enabled, so a
 *                     tool-augmented request that costs up to 3 upstream
 *                     calls also costs up to 3 units of rate-limit budget —
 *                     restoring "the limit bounds real upstream calls"
 *                     without the admin having to divide their configured
 *                     limits by 3 by hand. Tools disabled (or not loaded)
 *                     -> $amount is always 1, i.e. byte-for-byte dev.5 (INV-1).
 * @return true|WP_REST_Response True if allowed, 503 response if throttled.
 */
function neccospeak_chat_rate_limit( $amount = 1 ) {
	$amount = max( 1, (int) $amount );

	$per_min = (int) neccospeak_option( 'rate_limit_per_min', 10 );
	if ( ! neccospeak_check_ip_rate_limit( 'chat', $per_min, $amount ) ) {
		return neccospeak_rate_limited_response( MINUTE_IN_SECONDS );
	}

	$g_hourly = (int) neccospeak_option( 'rate_limit_global_hourly', 200 );
	if ( ! neccospeak_check_global_rate_limit( 'chat', 'hourly', $g_hourly, HOUR_IN_SECONDS, $amount ) ) {
		return neccospeak_rate_limited_response( HOUR_IN_SECONDS );
	}

	$g_daily = (int) neccospeak_option( 'rate_limit_global_daily', 1000 );
	if ( ! neccospeak_check_global_rate_limit( 'chat', 'daily', $g_daily, DAY_IN_SECONDS, $amount ) ) {
		return neccospeak_rate_limited_response( HOUR_IN_SECONDS );
	}

	return true;
}

/**
 * Apply the realtime-token rate limits (own per-IP minute + global daily).
 *
 * Realtime sessions are expensive, so this endpoint has independent, tighter
 * buckets with their own counter keys.
 *
 * @return true|WP_REST_Response True if allowed, 503 response if throttled.
 */
function neccospeak_realtime_rate_limit() {
	$per_min = (int) neccospeak_option( 'realtime_limit_per_min', 3 );
	if ( ! neccospeak_check_ip_rate_limit( 'realtime', $per_min ) ) {
		return neccospeak_rate_limited_response( MINUTE_IN_SECONDS );
	}

	$g_daily = (int) neccospeak_option( 'realtime_global_daily', 200 );
	if ( ! neccospeak_check_global_rate_limit( 'realtime', 'daily', $g_daily, DAY_IN_SECONDS ) ) {
		return neccospeak_rate_limited_response( HOUR_IN_SECONDS );
	}

	return true;
}

/**
 * Shared preflight for the public widget endpoints (/chat, /realtime-token).
 *
 * Performs, in order: force the request to anonymous (design rev.2 §0.2.3-b),
 * the security guard (same-origin check; nonce is best-effort only, see
 * neccospeak_rest_guard()), the endpoint-appropriate rate limits (503 +
 * Retry-After), enabled/api_key presence (503). Returns a WP_REST_Response on
 * failure, or the decrypted API key (string) on success.
 *
 * @param WP_REST_Request $req    Request.
 * @param string          $bucket Endpoint bucket: 'chat' or 'realtime'.
 * @param array|null      $opts   Phase 6 G16 (design §5.8): neccospeak_get_options(),
 *                                 so the 'chat' bucket can size its rate-limit
 *                                 consumption to 1 + max_rounds when tools are
 *                                 enabled. Optional and function_exists()-guarded
 *                                 inside — omitting it (existing callers) keeps
 *                                 the amount at 1, i.e. unchanged dev.5 behavior.
 * @return string|WP_REST_Response Decrypted API key on success, error response on failure.
 */
function neccospeak_rest_preflight( $req, $bucket = 'chat', $opts = null ) {
	// Public widget endpoints are always anonymous, even for a visitor who
	// happens to hold a logged-in cookie (§0.2.3-b) — there is no
	// authenticated-only behavior on these routes, so treating everyone
	// identically removes any CSRF-relevant authenticated branch and is what
	// makes neccospeak_relax_nonce_for_public_routes() safe to allow through.
	wp_set_current_user( 0 );

	$guard = neccospeak_rest_guard( $req );
	if ( $guard instanceof WP_REST_Response ) {
		return $guard;
	}

	if ( 'realtime' === $bucket ) {
		$rl = neccospeak_realtime_rate_limit();
	} else {
		$amount = 1;
		if ( is_array( $opts ) && function_exists( 'neccospeak_tools_enabled' ) && neccospeak_tools_enabled( $opts ) ) {
			$max_rounds = function_exists( 'neccospeak_tools_max_rounds' ) ? (int) neccospeak_tools_max_rounds() : 0;
			$amount     = 1 + max( 0, $max_rounds );
		}
		$rl = neccospeak_chat_rate_limit( $amount );
	}
	if ( $rl instanceof WP_REST_Response ) {
		return $rl;
	}

	$enabled = (bool) neccospeak_option( 'enabled', true );
	if ( ! $enabled ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Service unavailable.', 'neccospeak' ) ),
			503
		);
	}

	// Phase 5 G3(c) (design §6.5(A)): a budget-driven circuit-breaker pause.
	// Deliberately does NOT touch the 'enabled' option (the widget itself
	// stays visible; only the request-time response changes) and reuses the
	// existing 'Service unavailable.' string so a paused site's budget status
	// is never leaked to an outside observer. function_exists()-guarded per
	// the §1.3 cross-agent contract: includes/db.php may not be loaded yet.
	if ( function_exists( 'neccospeak_runtime_is_paused' )
		&& neccospeak_runtime_is_paused( 'realtime' === $bucket ? 'voice' : 'chat' ) ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Service unavailable.', 'neccospeak' ) ),
			503
		);
	}

	$enc = (string) neccospeak_option( 'openai_api_key', '' );
	$key = '' !== $enc ? (string) neccospeak_decrypt( $enc ) : '';
	if ( '' === $key ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Service unavailable.', 'neccospeak' ) ),
			503
		);
	}

	return $key;
}

/**
 * Sanitize a single chat message's content for safe storage/transmission.
 *
 * Unlike sanitize_textarea_field()/sanitize_text_field() (which mangle
 * percent-encoded "%XX" sequences and strip "<"/">" — unsuitable for chat
 * text that may legitimately contain URLs, code snippets, or generics like
 * "List<int>"), this sanitizer:
 *   - normalizes CRLF/CR line endings to LF
 *   - strips ASCII control bytes other than tab (0x09) and newline (0x0A)
 *   - strips invalid UTF-8 byte sequences (wp_check_invalid_utf8())
 *   - trims surrounding whitespace
 *   - byte-safely truncates to NECCOSPEAK_MAX_MSG_BYTES (neccospeak_strcut())
 *
 * It intentionally does NOT touch "<", ">", "&", or "%XX" — this value is
 * only ever sent to the OpenAI API as JSON, never rendered as HTML server-side.
 *
 * @param mixed $content Raw value (only strings are accepted).
 * @return string Sanitized content, or '' for non-strings / empty / whitespace-only.
 */
function neccospeak_sanitize_chat_message( $content ) {
	if ( ! is_string( $content ) ) {
		return '';
	}

	// Normalize CRLF / lone CR to LF.
	$content = str_replace( array( "\r\n", "\r" ), "\n", $content );

	// Strip invalid UTF-8 byte sequences, keeping the valid remainder.
	$content = wp_check_invalid_utf8( $content, true );

	// Strip ASCII control characters other than tab (0x09) and newline (0x0A).
	$content = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $content );

	$content = trim( $content );
	if ( '' === $content ) {
		return '';
	}

	return neccospeak_strcut( $content, NECCOSPEAK_MAX_MSG_BYTES );
}

/* -------------------------------------------------------------------------
 * G21: blocked-word filter (design rev.2 §7).
 *
 * Input gate (a) and output mask (b) share ONE pattern builder
 * (neccospeak_banned_pattern()) so they can never disagree about what
 * counts as a match — rev.1 used str_ireplace() for the mask and a separate
 * mb_convert_kana()+strpos() path for the gate, which (i) let mixed-width
 * forms like "ＢａＫａ" survive the output mask, and (ii) let a short ASCII
 * entry (e.g. "ai") block "explain"/"mail" outright (design rev.2 §7.1,
 * review C5).
 * ---------------------------------------------------------------------- */

/**
 * Every width variant of one banned word, longest first.
 *
 * Case is NOT expanded here — the compiled pattern's /i modifier handles it,
 * and under PHP's /u modifier PCRE2 enables UCP, so /i folds full-width
 * letters too (ＢａＫａ === ＢＡＫＡ). That is exactly what the old
 * str_ireplace() approach could not do (design rev.2 §7.1).
 *
 * Guarded with function_exists() so a build without mbstring degrades to the
 * word as typed rather than fataling (same policy as neccospeak_strcut()).
 *
 * @param string $word Banned word as the admin typed it.
 * @return string[] Deduplicated, non-empty variants, longest first.
 */
function neccospeak_banned_word_variants( $word ) {
	$v = array( (string) $word );
	if ( function_exists( 'mb_convert_kana' ) ) {
		foreach ( array( 'A', 'a', 'KV', 'k' ) as $m ) {
			$v[] = mb_convert_kana( (string) $word, $m, 'UTF-8' );
		}
	}
	$v = array_values(
		array_unique(
			array_filter(
				$v,
				static function ( $x ) {
					return is_string( $x ) && '' !== $x;
				}
			)
		)
	);
	usort(
		$v,
		static function ( $a, $b ) {
			return strlen( $b ) - strlen( $a );
		}
	);
	return $v;
}

/**
 * Compile one banned word into a single PCRE pattern used by BOTH the input
 * gate and the output mask, so the two can never disagree.
 *
 * /i — case-insensitive. /u — UTF-8 + UCP (PHP always sets PCRE2_UCP with
 * /u), which is what makes /i fold full-width letters.
 *
 * Boundary guards are added PER VARIANT, only to variants made entirely of
 * printable ASCII or printable full-width (Latin) characters, and use a
 * width-aware — but still script-narrow — lookaround, NOT \b:
 *   - \b under /u's PCRE2_UCP treats every Unicode letter (including
 *     hiragana/katakana/kanji) as a word character, so a \b-bounded ASCII
 *     variant sitting inside Japanese text (e.g. "baka" in "これはbakaです")
 *     would never match: 'は'/'で' both count as \w, so no boundary ever
 *     fires there. A lookaround built from an explicit "Latin word char"
 *     class — ASCII letters/digits/underscore PLUS their full-width
 *     (U+FF10-FF19/FF21-FF3A/FF41-FF5A/FF3F) counterparts — only cares
 *     whether the ADJACENT character is itself one of those, so it
 *     correctly fires between kana and Latin letters (either width) while
 *     still refusing to fire between two Latin letters of the same or mixed
 *     width (design rev.2 §7.1 follow-up).
 *   - Short entries without a guard are a foot-gun: 'ai' matches "explain",
 *     "mail" and "Thailand" — and, since mb_convert_kana() generates a
 *     full-width variant ("ａｉ") for every ASCII entry, it just as surely
 *     matches inside the full-width forms IME users type routinely
 *     ("ｍａｉｌ", "ｅｘｐｌａｉｎ", "Ｔｈａｉｌａｎｄ"). With banned_block_input
 *     on (default) that silently turns the whole chat into a canned reply.
 *     The guard fixes both widths while still blocking the standalone word,
 *     in either width (review C5; full-width regression found in a later
 *     review pass).
 *   - Variants that are NOT entirely ASCII/full-width printable (Japanese
 *     kana/kanji such as "ばか") still get NO guard at all: Japanese has no
 *     word boundaries, so substring semantics apply there, unchanged.
 *
 * Memoized per request (word -> pattern): this is called once per (word,
 * candidate text) pair from neccospeak_banned_match()/neccospeak_banned_mask_text(),
 * so without memoization a single request scanning many messages against many
 * banned words recompiles the same variant/pattern work over and over
 * (design rev.2 §7.4 perf follow-up).
 *
 * @param string $word Banned word as the admin typed it.
 * @return string Compiled pattern, or '' when it could not be compiled.
 */
function neccospeak_banned_pattern( $word ) {
	static $cache = array();

	$key = (string) $word;
	if ( array_key_exists( $key, $cache ) ) {
		return $cache[ $key ];
	}

	$variants = neccospeak_banned_word_variants( $word );
	if ( empty( $variants ) ) {
		$cache[ $key ] = '';
		return '';
	}

	// "Latin word char": ASCII letters/digits/underscore, or their full-width
	// counterparts (what mb_convert_kana( ..., 'A' ) produces from ASCII).
	// Used for the boundary lookaround AND to decide which variants are
	// "Latin" (guarded) vs. Japanese kana/kanji (unguarded, substring
	// semantics) — see the docblock above.
	$word_char = 'A-Za-z0-9_\x{FF10}-\x{FF19}\x{FF21}-\x{FF3A}\x{FF41}-\x{FF5A}\x{FF3F}';

	$alt = array();
	foreach ( $variants as $v ) {
		$q = preg_quote( $v, '/' );
		// Not entirely ASCII-or-full-width printable: Japanese kana/kanji
		// (or any other script) — no guard, substring semantics.
		if ( 1 !== preg_match( '/^[\x20-\x7E\x{FF01}-\x{FF5E}]+$/u', $v ) ) {
			$alt[] = $q;
			continue;
		}
		$prefix = ( 1 === preg_match( '/^[' . $word_char . ']/u', $v ) ) ? '(?<![' . $word_char . '])' : '';
		$suffix = ( 1 === preg_match( '/[' . $word_char . ']$/u', $v ) ) ? '(?![' . $word_char . '])' : '';
		$alt[]  = $prefix . $q . $suffix;
	}

	$pattern = '/(?:' . implode( '|', $alt ) . ')/iu';
	// Fail-open for this one word: an unusual admin-typed string could in
	// theory not compile; never fatal the whole request over it.
	if ( false === @preg_match( $pattern, '' ) ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- deliberate fail-open probe, see docblock.
		$pattern = '';
	}

	$cache[ $key ] = $pattern;
	return $pattern;
}

/**
 * Parse the banned_words textarea into a usable word list.
 *
 * Entries shorter than NECCOSPEAK_BANNED_MIN_CHARS characters are discarded:
 * a single-character entry (ASCII "a", or one kanji) would match essentially
 * every message and silently turn the whole chat off. Two-character ASCII
 * entries are kept but are made \b-bounded by neccospeak_banned_pattern()
 * (design rev.2 §7.1) so they no longer match inside longer words.
 *
 * @param array $opts neccospeak_get_options() (or any subset).
 * @return string[] Words as typed (NOT normalized), max NECCOSPEAK_BANNED_MAX_WORDS.
 */
function neccospeak_banned_words_list( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$raw  = isset( $opts['banned_words'] ) ? (string) $opts['banned_words'] : '';
	if ( '' === trim( $raw ) ) {
		return array();
	}

	$lines = preg_split( '/\R+/u', $raw );
	if ( ! is_array( $lines ) ) {
		return array();
	}

	$out = array();
	foreach ( $lines as $line ) {
		$w = trim( (string) $line );
		if ( '' === $w ) {
			continue;
		}
		$len = function_exists( 'mb_strlen' ) ? mb_strlen( $w, 'UTF-8' ) : strlen( $w );
		if ( $len < NECCOSPEAK_BANNED_MIN_CHARS ) {
			continue;
		}
		$out[] = $w;
	}

	$out = array_values( array_unique( $out ) );
	return array_slice( $out, 0, NECCOSPEAK_BANNED_MAX_WORDS );
}

/**
 * @param string   $text  Message text to test.
 * @param string[] $words neccospeak_banned_words_list().
 * @return string The first matching word as typed, or '' when nothing matched.
 */
function neccospeak_banned_match( $text, $words ) {
	$text = (string) $text;
	if ( '' === $text || empty( $words ) ) {
		return '';
	}
	foreach ( $words as $w ) {
		$p = neccospeak_banned_pattern( $w );
		if ( '' !== $p && 1 === preg_match( $p, $text ) ) {
			return $w;
		}
	}
	return '';
}

/**
 * @param string   $text  Upstream reply text.
 * @param string[] $words neccospeak_banned_words_list().
 * @return string Text with every banned surface form replaced by NECCOSPEAK_BANNED_MASK.
 */
function neccospeak_banned_mask_text( $text, $words ) {
	$text = (string) $text;
	if ( '' === $text || empty( $words ) ) {
		return $text;
	}
	foreach ( $words as $w ) {
		$p = neccospeak_banned_pattern( $w );
		if ( '' === $p ) {
			continue;
		}
		$r = preg_replace( $p, NECCOSPEAK_BANNED_MASK, $text );
		if ( null !== $r ) {
			// Fail-open on a preg_replace() failure (e.g. backtrack limit):
			// keep whatever text we had rather than losing the whole reply.
			$text = $r;
		}
	}
	return $text;
}

/**
 * @param array $opts neccospeak_get_options().
 * @return string Non-empty canned reply.
 */
function neccospeak_banned_blocked_reply( $opts ) {
	$opts   = is_array( $opts ) ? $opts : array();
	$custom = isset( $opts['banned_reply_text'] ) ? trim( (string) $opts['banned_reply_text'] ) : '';
	if ( '' !== $custom ) {
		return $custom;
	}
	return __( 'Sorry — I am not able to help with that topic here. If you have another question about this site, I am happy to answer it.', 'neccospeak' );
}

/**
 * Structural normalization shared by neccospeak_sanitize_and_clamp_messages()
 * and (through it) both neccospeak_extract_user_texts() and
 * neccospeak_build_messages(): decode a /chat body into one raw
 * [{role, content}, ...] list, accepting EITHER shape the shipped widget can
 * send (assets/js/widget-core.js:1021 sends the legacy {message, history}
 * shape, not {messages} — design rev.2 §7.4 follow-up, review finding "G21
 * never fires for the shipped widget"):
 *   - messages: [{role, content}, ...]
 *   - {message: string, history: [{role, content}, ...]}
 * 'messages' wins when both are present (same precedence as before). Pure:
 * no options read, no side effects, no sanitizing (that is
 * neccospeak_sanitize_and_clamp_messages()'s job).
 *
 * @param mixed $body Decoded JSON body (any shape).
 * @return array Raw, unsanitized [{role, content}] list (may be empty).
 */
function neccospeak_normalize_chat_input( $body ) {
	if ( ! is_array( $body ) ) {
		return array();
	}

	if ( isset( $body['messages'] ) && is_array( $body['messages'] ) ) {
		return $body['messages'];
	}

	$raw = array();
	if ( isset( $body['history'] ) && is_array( $body['history'] ) ) {
		$raw = $body['history'];
	}
	if ( isset( $body['message'] ) && is_string( $body['message'] ) && '' !== trim( $body['message'] ) ) {
		$raw[] = array(
			'role'    => 'user',
			'content' => $body['message'],
		);
	}
	return $raw;
}

/**
 * Normalize (both body shapes), sanitize every message's content, drop any
 * role other than user/assistant, and clamp the list to
 * NECCOSPEAK_MAX_HISTORY_MSGS (most-recent kept) — everything
 * neccospeak_build_messages() used to do BEFORE PII masking, now factored out
 * so the G21 input gate (neccospeak_extract_user_texts()) sees exactly the
 * same normalized, sanitized, count-clamped message set that is actually
 * going to be sent, instead of the raw, unbounded request body.
 *
 * This is deliberately what the gate runs on, not the raw body, for two
 * reasons (design rev.2 §7.4 follow-up, review):
 *   1. Coverage — the raw body's shape rule used to be `messages`-only, so
 *      the gate silently never fired for the shipped widget's legacy
 *      {message, history} shape. Sharing this function with
 *      neccospeak_build_messages() makes that structurally impossible now.
 *   2. Fidelity — neccospeak_sanitize_chat_message() strips ASCII control
 *      bytes before the gate ever sees the text. Scanning the RAW body let a
 *      banned word survive the gate by hiding an invisible control byte
 *      (e.g. BEL, \x07) in the middle of it: the gate's unsanitized copy
 *      didn't match, but neccospeak_build_messages() went on to sanitize the
 *      same text (control byte removed) and forward the now-intact banned
 *      word upstream. Gating on the SANITIZED text is what OpenAI actually
 *      receives, so the two can no longer disagree. C4's actual requirement
 *      — the gate must run BEFORE PII masking, so a numeric banned word never
 *      hides behind "[phone]" — is unaffected: masking still happens after
 *      this function returns (see neccospeak_build_messages()).
 *   3. Cost — clamping to NECCOSPEAK_MAX_HISTORY_MSGS BEFORE the gate's
 *      word-list scan (rather than after) bounds the gate's cost to the
 *      messages that are actually going to be sent, closing an unauthenticated
 *      CPU-amplification path where an oversized body with thousands of
 *      messages made the gate scan far more text than would ever reach OpenAI.
 *
 * @param mixed $body Decoded JSON body (any shape).
 * @return array<int,array{role:string,content:string}> Sanitized, count-clamped list (may be empty).
 */
function neccospeak_sanitize_and_clamp_messages( $body ) {
	$raw   = neccospeak_normalize_chat_input( $body );
	$clean = array();
	foreach ( $raw as $item ) {
		if ( ! is_array( $item ) ) {
			continue;
		}
		$role = isset( $item['role'] ) ? (string) $item['role'] : '';
		// Only accept user/assistant from the client; never client-supplied system/tool.
		if ( 'user' !== $role && 'assistant' !== $role ) {
			continue;
		}

		$content = isset( $item['content'] ) ? $item['content'] : '';
		$content = neccospeak_sanitize_chat_message( $content );
		if ( '' === $content ) {
			continue;
		}

		$clean[] = array(
			'role'    => $role,
			'content' => $content,
		);
	}

	if ( count( $clean ) > NECCOSPEAK_MAX_HISTORY_MSGS ) {
		$clean = array_slice( $clean, -NECCOSPEAK_MAX_HISTORY_MSGS );
	}

	return array_values( $clean );
}

/**
 * Every user-authored message text that will actually be considered for
 * upstream delivery, in order, sanitized and count-clamped, BEFORE any PII
 * masking.
 *
 * The G21 input gate runs on this so the blocklist sees exactly what the
 * visitor typed, after the same sanitizing/normalizing
 * neccospeak_build_messages() applies (see
 * neccospeak_sanitize_and_clamp_messages()'s docblock for why: coverage of
 * both body shapes, fidelity with what OpenAI receives, and a bounded scan
 * cost) but strictly BEFORE PII masking — masking first would hide a numeric
 * banned word behind "[phone]" (design rev.2 §7.4, review C4). Pure: no
 * options read, no side effects.
 *
 * @param mixed $body Decoded JSON body (any shape).
 * @return string[] Sanitized, non-empty user texts, in order (may be empty).
 */
function neccospeak_extract_user_texts( $body ) {
	$out = array();
	foreach ( neccospeak_sanitize_and_clamp_messages( $body ) as $m ) {
		if ( 'user' === $m['role'] ) {
			$out[] = $m['content'];
		}
	}
	return $out;
}

/* -------------------------------------------------------------------------
 * G20 (a): server-side PII masking (design rev.2 §8.1).
 *
 * Two-stage approach (extract numeric candidates, THEN classify them) is
 * deliberate: a single "phone regex" and a single "card regex" run side by
 * side would match each other's territory (a phone pattern can match part of
 * a card number and vice versa). Extracting once and classifying by digit
 * count + Luhn removes both the ordering dependency and the false positives.
 * ---------------------------------------------------------------------- */

/**
 * @param array $opts neccospeak_get_options() (or subset).
 * @return string[] Enabled PII categories, canonical order: email, phone, card.
 */
function neccospeak_pii_types( $opts ) {
	$allowed = array( 'email', 'phone', 'card' );
	$opts    = is_array( $opts ) ? $opts : array();
	// Absent option key -> the documented default (all three enabled); an
	// explicitly saved (possibly empty) array is honored as-is.
	$raw = ( isset( $opts['pii_types'] ) && is_array( $opts['pii_types'] ) ) ? $opts['pii_types'] : $allowed;

	$out = array();
	foreach ( $allowed as $t ) {
		if ( in_array( $t, $raw, true ) ) {
			$out[] = $t;
		}
	}
	return $out;
}

/**
 * Fold full-width digits/symbols to half-width, then keep only ASCII digits.
 *
 * @param string $s Candidate substring (may contain separators, +/＋, etc).
 * @return string Digits only, e.g. '０９０-1234' -> '0901234'.
 */
function neccospeak_ascii_digits( $s ) {
	$s = (string) $s;
	if ( function_exists( 'mb_convert_kana' ) ) {
		$s = mb_convert_kana( $s, 'a', 'UTF-8' ); // Full-width alnum/symbols -> half-width.
	}
	return (string) preg_replace( '/[^0-9]/', '', $s );
}

/**
 * Luhn (mod 10) checksum.
 *
 * @param string $digits ASCII-digit-only string (e.g. from neccospeak_ascii_digits()).
 * @return bool
 */
function neccospeak_luhn_check( $digits ) {
	$digits = (string) $digits;
	$len    = strlen( $digits );
	if ( $len < 1 || 1 !== preg_match( '/^[0-9]+$/', $digits ) ) {
		return false;
	}
	$sum = 0;
	$alt = false;
	for ( $i = $len - 1; $i >= 0; $i-- ) {
		$n = (int) $digits[ $i ];
		if ( $alt ) {
			$n *= 2;
			if ( $n > 9 ) {
				$n -= 9;
			}
		}
		$sum += $n;
		$alt  = ! $alt;
	}
	return 0 === ( $sum % 10 );
}

/**
 * Classify one already-extracted numeric candidate as 'card', 'phone', or ''
 * (design rev.2 §8.1 classification table). $types gates which categories
 * are even considered, so a category the admin disabled can never match.
 *
 * @param string   $candidate Raw matched substring (with separators/+).
 * @param string[] $types     Subset of array('email','phone','card').
 * @return string 'card' | 'phone' | ''.
 */
function neccospeak_pii_numeric_candidate_class( $candidate, $types ) {
	$has_plus = 1 === preg_match( '/^[\+\x{FF0B}]/u', (string) $candidate );
	$digits   = neccospeak_ascii_digits( $candidate );
	$len      = strlen( $digits );

	if ( in_array( 'card', $types, true ) && $len >= 13 && $len <= 19 && neccospeak_luhn_check( $digits ) ) {
		return 'card';
	}
	if ( in_array( 'phone', $types, true ) ) {
		if ( $has_plus && $len >= 8 && $len <= 15 ) {
			return 'phone';
		}
		if ( ! $has_plus && ( 10 === $len || 11 === $len ) && '0' === substr( $digits, 0, 1 ) ) {
			return 'phone';
		}
	}
	return '';
}

/**
 * Mask PII in $text. Pure — no options read, no side effects.
 *
 * Processing order: email first (so an email's digits never feed the numeric
 * pass), then one preg_replace_callback() pass over numeric candidates.
 *
 * @param string   $text  Raw message text.
 * @param string[] $types Subset of array('email','phone','card').
 * @return string
 */
function neccospeak_pii_mask( $text, $types ) {
	$text  = (string) $text;
	$types = is_array( $types ) ? $types : array();
	if ( '' === $text || empty( $types ) ) {
		return $text;
	}

	if ( in_array( 'email', $types, true ) ) {
		$r = preg_replace( NECCOSPEAK_PII_EMAIL_PATTERN, NECCOSPEAK_PII_MASK_EMAIL, $text );
		if ( null !== $r ) {
			$text = $r;
		}
	}

	if ( in_array( 'phone', $types, true ) || in_array( 'card', $types, true ) ) {
		$r = preg_replace_callback(
			NECCOSPEAK_PII_NUMERIC_PATTERN,
			function ( $m ) use ( $types ) {
				$class = neccospeak_pii_numeric_candidate_class( $m[0], $types );
				if ( 'card' === $class ) {
					return NECCOSPEAK_PII_MASK_CARD;
				}
				if ( 'phone' === $class ) {
					return NECCOSPEAK_PII_MASK_PHONE;
				}
				return $m[0]; // Not classified: leave untouched (false-positive-safe).
			},
			$text
		);
		if ( null !== $r ) {
			$text = $r;
		}
	}

	return $text;
}

/**
 * Which PII categories occur in $text (detection only, for tests/diagnostics
 * and for assets/js/widget-pii.js's PHP-mirrored client-side logic).
 *
 * @param string   $text  Message text.
 * @param string[] $types Subset of array('email','phone','card').
 * @return string[] Detected categories, canonical order, deduplicated.
 */
function neccospeak_pii_detect( $text, $types ) {
	$text  = (string) $text;
	$types = is_array( $types ) ? $types : array();
	$found = array();
	if ( '' === $text || empty( $types ) ) {
		return $found;
	}

	if ( in_array( 'email', $types, true ) && 1 === preg_match( NECCOSPEAK_PII_EMAIL_PATTERN, $text ) ) {
		$found[] = 'email';
	}

	if ( in_array( 'phone', $types, true ) || in_array( 'card', $types, true ) ) {
		$scan = preg_replace( NECCOSPEAK_PII_EMAIL_PATTERN, ' ', $text );
		if ( null === $scan ) {
			$scan = $text;
		}
		if ( preg_match_all( NECCOSPEAK_PII_NUMERIC_PATTERN, $scan, $matches ) ) {
			foreach ( $matches[0] as $candidate ) {
				$class = neccospeak_pii_numeric_candidate_class( $candidate, $types );
				if ( '' !== $class && ! in_array( $class, $found, true ) ) {
					$found[] = $class;
				}
			}
		}
	}

	$order = array( 'email', 'phone', 'card' );
	$out   = array();
	foreach ( $order as $o ) {
		if ( in_array( $o, $found, true ) ) {
			$out[] = $o;
		}
	}
	return $out;
}

/**
 * Normalize, sanitize, mask, and clamp the incoming message list into what
 * actually gets sent upstream.
 *
 * Accepts either:
 *   - messages: [{role, content}, ...]   (role limited to user|assistant)
 *   - {message: string, history: [{role, content}, ...]}
 * (neccospeak_normalize_chat_input(), shared with neccospeak_extract_user_texts()
 * so the G21 gate and this function can never disagree about which messages
 * exist — design rev.2 §7.4 follow-up.)
 *
 * System prompts from the client are dropped (system is server-generated only,
 * Recon Issue #265). Each message's content goes through
 * neccospeak_sanitize_chat_message() and the list is clamped to
 * NECCOSPEAK_MAX_HISTORY_MSGS (neccospeak_sanitize_and_clamp_messages(), also
 * shared with the gate), then PII-masked, then clamped to
 * NECCOSPEAK_MAX_HISTORY_BYTES (most-recent kept).
 *
 * @param array $body Decoded JSON body.
 * @param array $opts Plugin options. Defaults to array(), which disables every
 *                    Phase 3 transformation — so every existing 1-argument
 *                    caller (tests/test-sanitize.php:110-168) behaves exactly
 *                    as before (no regression, design rev.2 §8.2).
 * @return array Sanitized [{role, content}] list (may be empty).
 */
function neccospeak_build_messages( $body, $opts = array() ) {
	$clean = neccospeak_sanitize_and_clamp_messages( $body );

	// G20 (a): mask PII before anything leaves this server.
	//
	// SCOPE = role 'user' only (design rev.2 §8.2, review C8). Masking the
	// assistant's own past turns would contradict the system prompt, which is
	// never masked: if the company / FAQ sections list the shop's phone number
	// and the assistant quotes it once, masking it out of the history on the
	// next turn shows the model "real number in system, redacted in history"
	// and it can no longer answer "what was that number again?". G20's
	// purpose is to stop the VISITOR's own PII from leaving the site, and
	// user-only masking satisfies that completely.
	//
	// Runs AFTER the count clamp above — masking cannot change how many
	// messages there are, so nothing is gained by masking messages the count
	// clamp is about to discard (design rev.2 §7.4 perf follow-up) — but
	// BEFORE the byte clamp below (mask can only shrink text, so masking
	// first is what lets a message that would otherwise have been clamped out
	// survive under the byte budget).
	if ( is_array( $opts ) && ! empty( $opts['pii_mask_server'] ) ) {
		$pii_types = neccospeak_pii_types( $opts );
		if ( ! empty( $pii_types ) ) {
			foreach ( $clean as $i => $m ) {
				if ( 'user' !== $m['role'] ) {
					continue;
				}
				$clean[ $i ]['content'] = neccospeak_pii_mask( $m['content'], $pii_types );
			}
		}
	}

	// Clamp by byte budget (drop oldest until under budget).
	$bytes = 0;
	foreach ( $clean as $m ) {
		$bytes += strlen( $m['content'] );
	}
	while ( count( $clean ) > 1 && $bytes > NECCOSPEAK_MAX_HISTORY_BYTES ) {
		$dropped = array_shift( $clean );
		$bytes  -= strlen( $dropped['content'] );
	}

	return array_values( $clean );
}

/**
 * Transient key holding a model's learned parameter quirks (G17).
 *
 * @param string $model Model id.
 * @return string
 */
function neccospeak_model_quirks_key( $model ) {
	return 'neccospeak_quirks_' . md5( (string) $model );
}

/**
 * Get the learned parameter quirks for a model: which params it has
 * previously rejected (400), and what we now do about the token-limit param
 * name. Consulted by neccospeak_build_chat_payload() so a model that has
 * already rejected a parameter once is not sent it again on every request.
 * Also consumed by Diagnostics F1 (design rev.2 §10.1) to show learned
 * quirks for the currently configured model.
 *
 * @param string $model Model id.
 * @return array {
 *     @type string[] dropped          Param names to never send again (e.g. 'temperature').
 *     @type string    max_tokens_param 'max_tokens' | 'max_completion_tokens' | '' (never send either).
 * }
 */
function neccospeak_model_quirks( $model ) {
	$defaults = array(
		'dropped'          => array(),
		'max_tokens_param' => 'max_tokens',
	);

	$model = (string) $model;
	if ( '' === $model ) {
		return $defaults;
	}

	$stored = get_transient( neccospeak_model_quirks_key( $model ) );
	if ( ! is_array( $stored ) ) {
		return $defaults;
	}

	return array_merge( $defaults, $stored );
}

/**
 * Record that $model rejected $param (via a 400 error) so future requests for
 * this model skip sending it (or swap it) proactively.
 *
 * @param string $model Model id.
 * @param string $param 'temperature' | 'max_tokens' | 'max_completion_tokens'.
 * @return void
 */
function neccospeak_remember_model_quirk( $model, $param ) {
	$model = (string) $model;
	if ( '' === $model ) {
		return;
	}

	$quirks = neccospeak_model_quirks( $model );

	if ( 'temperature' === $param ) {
		if ( ! in_array( 'temperature', $quirks['dropped'], true ) ) {
			$quirks['dropped'][] = 'temperature';
		}
	} elseif ( 'max_tokens' === $param ) {
		$quirks['max_tokens_param'] = 'max_completion_tokens';
	} elseif ( 'max_completion_tokens' === $param ) {
		// Neither param name is accepted by this model; stop sending a token cap.
		$quirks['max_tokens_param'] = '';
		if ( ! in_array( 'max_tokens', $quirks['dropped'], true ) ) {
			$quirks['dropped'][] = 'max_tokens';
		}
	} elseif ( 'stream_options' === $param ) {
		// Phase 6 G29 (design §7.3.7/S22): this model/proxy rejects
		// stream_options, so neccospeak_stream_mode() should stop offering SSE
		// for it. Only ever appended to 'dropped' — neccospeak_build_chat_payload()
		// reads 'dropped' solely for its 'temperature'/'max_tokens' checks, so
		// the non-stream payload is byte-for-byte unchanged (INV-1).
		if ( ! in_array( 'stream_options', $quirks['dropped'], true ) ) {
			$quirks['dropped'][] = 'stream_options';
		}
	} else {
		return; // Unknown param; nothing to remember.
	}

	set_transient( neccospeak_model_quirks_key( $model ), $quirks, NECCOSPEAK_QUIRKS_CACHE_TTL );
}

/**
 * Phase 6 G29 (design §2.5 S22): has $model previously rejected $param?
 * Currently only meaningful for 'stream_options' (neccospeak_stream_mode()'s
 * SSE-eligibility check), but reads the same general 'dropped' list as
 * neccospeak_build_chat_payload() so it stays correct if more params are
 * ever added there.
 *
 * @param string $model Model id.
 * @param string $param Param name, e.g. 'stream_options'.
 * @return bool
 */
function neccospeak_model_quirk_has( $model, $param ) {
	$quirks = neccospeak_model_quirks( $model );
	return in_array( (string) $param, $quirks['dropped'], true );
}

/**
 * Build the Chat Completions payload (G17), applying the model's learned
 * parameter quirks so a param it has already rejected is not resent.
 *
 * @param string $model    Model id.
 * @param array  $messages Sanitized message list (system prompt already prepended).
 * @param array  $opts     Plugin options (neccospeak_get_options()).
 * @return array Payload ready for wp_json_encode().
 */
function neccospeak_build_chat_payload( $model, $messages, $opts ) {
	$payload = array(
		'model'    => $model,
		'messages' => $messages,
		// v1: no streaming (full text returned). Recon shape allows stream:false.
		'stream'   => false,
	);

	$quirks = neccospeak_model_quirks( $model );

	$temperature = isset( $opts['temperature'] ) ? $opts['temperature'] : null;
	if ( null !== $temperature && is_numeric( $temperature ) && ! in_array( 'temperature', $quirks['dropped'], true ) ) {
		$payload['temperature'] = (float) $temperature;
	}

	$max_tokens = isset( $opts['max_tokens'] ) ? $opts['max_tokens'] : null;
	if ( null !== $max_tokens && is_numeric( $max_tokens ) && '' !== $quirks['max_tokens_param'] && ! in_array( 'max_tokens', $quirks['dropped'], true ) ) {
		$payload[ $quirks['max_tokens_param'] ] = (int) $max_tokens;
	}

	return $payload;
}

/**
 * Send one Chat Completions request. Factored out of neccospeak_rest_chat()
 * so the G17 400-retry can call it a second time without duplicating the
 * headers/timeout.
 *
 * @param array  $payload Request payload.
 * @param string $api_key Decrypted OpenAI API key.
 * @param int    $timeout Seconds (design §5.4). Default 60 keeps every
 *                         existing call site (and existing test) byte-for-byte
 *                         identical to dev.5 (INV-1); the G16 tool loop
 *                         (§5.3) is the only caller that passes a computed
 *                         value (neccospeak_chat_upstream_timeout()).
 * @return array|WP_Error wp_remote_post() result.
 */
function neccospeak_openai_chat_request( $payload, $api_key, $timeout = 60 ) {
	return wp_remote_post(
		NECCOSPEAK_OPENAI_CHAT_URL,
		array(
			'timeout' => max( 5, min( 120, (int) $timeout ) ),
			'headers' => array(
				'Authorization' => 'Bearer ' . $api_key,
				'Content-Type'  => 'application/json',
			),
			'body'    => wp_json_encode( $payload ),
		)
	);
}

/**
 * Given an OpenAI Chat Completions 400 error body and the payload that
 * produced it, build a retry payload that removes/swaps the offending
 * parameter (G17: some models reject "temperature", or want
 * "max_completion_tokens" instead of "max_tokens").
 *
 * Only ever adjusts a small allow-listed set of parameters; anything else
 * (no error, malformed JSON, or an unrecognized/non-string param) returns
 * null — meaning no retry should be attempted.
 *
 * @param string $error_body Raw JSON response body from the failed request.
 * @param array  $payload    The payload that was sent (and rejected).
 * @return array|null Adjusted payload to retry with, or null if no safe retry applies.
 */
function neccospeak_chat_retry_payload( $error_body, $payload ) {
	if ( ! is_array( $payload ) ) {
		return null;
	}

	$data = json_decode( (string) $error_body, true );
	if ( ! is_array( $data ) || ! isset( $data['error'] ) || ! is_array( $data['error'] ) ) {
		return null;
	}

	$param = isset( $data['error']['param'] ) ? $data['error']['param'] : null;
	if ( ! is_string( $param ) || '' === $param ) {
		return null;
	}

	$allowed = array( 'temperature', 'max_tokens', 'max_completion_tokens' );
	if ( ! in_array( $param, $allowed, true ) ) {
		return null;
	}

	$retry = $payload;

	if ( 'temperature' === $param ) {
		if ( ! array_key_exists( 'temperature', $retry ) ) {
			return null;
		}
		unset( $retry['temperature'] );
		return $retry;
	}

	if ( 'max_tokens' === $param ) {
		if ( ! array_key_exists( 'max_tokens', $retry ) ) {
			return null;
		}
		$retry['max_completion_tokens'] = $retry['max_tokens'];
		unset( $retry['max_tokens'] );
		return $retry;
	}

	// 'max_completion_tokens' === $param.
	if ( ! array_key_exists( 'max_completion_tokens', $retry ) ) {
		return null;
	}
	unset( $retry['max_completion_tokens'] );
	return $retry;
}

/**
 * G17 v2: decide whether the adaptive retry loop should make ANOTHER
 * adjustment to a rejected payload, and produce it if so.
 *
 * Some models (e.g. the GPT-5 family) reject more than one allow-listed
 * param within the SAME request — first "max_tokens", then, once that's
 * swapped for "max_completion_tokens", also "temperature". A single retry
 * is not enough to reach a payload the model will accept, so both the
 * non-stream tool loop (neccospeak_rest_chat()) and the SSE emit path
 * (neccospeak_rest_chat_stream_emit()) drive this helper in a bounded loop
 * instead of calling neccospeak_chat_retry_payload() once. Centralizing the
 * "which param, have we already fixed it, are we out of attempts" decision
 * here keeps both call sites from drifting apart as this grows.
 *
 * Returns null (meaning "stop adapting, fall through to the normal error
 * path") whenever:
 * - the cap (NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS) has already been reached;
 * - the 400 doesn't name a param, or names one already adjusted THIS
 *   request (so an upstream that keeps rejecting the same param can never
 *   cause more than one adaptation for it — no infinite loop even against
 *   an adversarial/buggy upstream);
 * - neccospeak_chat_retry_payload() itself can't build an adjustment (param
 *   not in its allow-list, or not actually present in $payload).
 *
 * @param string $error_body      Raw JSON body of the failed (400) response.
 * @param array  $payload         The payload that was just rejected.
 * @param array  $adapted_params  Params already adjusted this request, as a
 *                                  set (param name => true) for O(1) lookup.
 * @param int    $adaptations_done How many adaptations already applied this request.
 * @return array{payload:array,param:string}|null The adjusted payload and
 *                                                   the param name it fixed,
 *                                                   or null to stop.
 */
function neccospeak_chat_g17_next_adaptation( $error_body, array $payload, array $adapted_params, $adaptations_done ) {
	if ( (int) $adaptations_done >= NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS ) {
		return null;
	}

	$data      = json_decode( (string) $error_body, true );
	$bad_param = ( is_array( $data ) && isset( $data['error']['param'] ) && is_string( $data['error']['param'] ) )
		? $data['error']['param']
		: '';

	if ( '' === $bad_param || isset( $adapted_params[ $bad_param ] ) ) {
		return null;
	}

	$retry_payload = neccospeak_chat_retry_payload( $error_body, $payload );
	if ( null === $retry_payload ) {
		return null;
	}

	return array(
		'payload' => $retry_payload,
		'param'   => $bad_param,
	);
}

/* =========================================================================
 * Phase 6 G16 — function-calling tool loop support (design §5).
 *
 * neccospeak_tools_*() itself lives in includes/tools.php (impl-tools); every
 * call into it below is function_exists()-guarded per §1.3. The pure helpers
 * in this section (upstream timeout budgeting, tool_calls extraction, usage
 * merge/estimate, tool-result message shaping) belong to THIS file's owner
 * (impl-chat) regardless of whether tools.php is loaded, and are exercised
 * directly by tests/test-rest.php.
 * ====================================================================== */

/**
 * Split NECCOSPEAK_CHAT_UPSTREAM_BUDGET across the upstream calls a tool
 * round can still make (design §5.4). Reserves NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT
 * seconds for each round still to come after this one, so an early round
 * never starves a later one of its minimum.
 *
 * @param float $deadline_ts microtime(true) deadline for ALL upstream calls.
 * @param int   $rounds_left How many tool rounds may still follow this call.
 * @return int Seconds to pass to neccospeak_openai_chat_request()'s $timeout.
 */
function neccospeak_chat_upstream_timeout( $deadline_ts, $rounds_left ) {
	$remaining = (int) floor( (float) $deadline_ts - microtime( true ) );
	$reserved  = max( 0, (int) $rounds_left ) * NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT;
	$budget    = $remaining - $reserved;
	if ( $budget < NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT ) {
		$budget = NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT;
	}
	return (int) min( NECCOSPEAK_CHAT_UPSTREAM_MAX_TIMEOUT, $budget );
}

/**
 * Was this wp_remote_post()/wp_remote_get() result a successful (2xx) HTTP
 * response? Factored out of the ad hoc `$code < 200 || $code >= 300` checks
 * scattered through this file (design §2.5 S11) so the G16 tool loop's state
 * machine (§5.3) has one place to ask the question.
 *
 * @param array|WP_Error $response wp_remote_*() result.
 * @return bool
 */
function neccospeak_http_2xx( $response ) {
	if ( is_wp_error( $response ) ) {
		return false;
	}
	$code = (int) wp_remote_retrieve_response_code( $response );
	return $code >= 200 && $code < 300;
}

/**
 * Normalize `choices[0].message.tool_calls` from a decoded Chat Completions
 * response into the shape the tool loop (§5.3) and neccospeak_chat_tool_messages()
 * (S9) both expect. Rows with a missing id/function name, or a name that
 * doesn't match includes/tools.php's own allow-list pattern
 * (`/^[a-z][a-z0-9_]{0,31}$/` — design §2.3 T1), are dropped rather than
 * passed through: a malformed or spoofed tool_calls entry must never reach
 * neccospeak_tools_execute() (design §2.5 S12).
 *
 * @param mixed $data Decoded upstream JSON body (or anything else).
 * @return array<int,array{id:string,name:string,arguments:string}>
 */
function neccospeak_chat_extract_tool_calls( $data ) {
	if ( ! is_array( $data ) || ! isset( $data['choices'][0]['message']['tool_calls'] ) || ! is_array( $data['choices'][0]['message']['tool_calls'] ) ) {
		return array();
	}

	$out = array();
	foreach ( $data['choices'][0]['message']['tool_calls'] as $tc ) {
		if ( ! is_array( $tc ) ) {
			continue;
		}
		$id = isset( $tc['id'] ) && is_string( $tc['id'] ) ? $tc['id'] : '';
		if ( '' === $id ) {
			continue;
		}
		$name = ( isset( $tc['function']['name'] ) && is_string( $tc['function']['name'] ) ) ? $tc['function']['name'] : '';
		if ( 1 !== preg_match( '/^[a-z][a-z0-9_]{0,31}$/', $name ) ) {
			continue;
		}
		$arguments = ( isset( $tc['function']['arguments'] ) && is_string( $tc['function']['arguments'] ) ) ? $tc['function']['arguments'] : '';

		$out[] = array(
			'id'        => $id,
			'name'      => $name,
			'arguments' => $arguments,
		);
	}
	return $out;
}

/**
 * Build the `assistant`(tool_calls) + `tool`×N message sequence the next
 * upstream round needs (design §5.3 SM-2). ALWAYS appends one `tool` message
 * per tool_call, even for a failed result — OpenAI 400s if a tool_calls
 * assistant turn is not immediately followed by a matching tool_call_id for
 * every call, so a dropped `tool` message would break the NEXT round, not
 * just degrade this one.
 *
 * @param array $tool_calls neccospeak_chat_extract_tool_calls() output (already round-capped).
 * @param array $results    Parallel array of neccospeak_tools_execute() results (or the
 *                           unavailable-tool fallback shape), same length/order as $tool_calls.
 * @return array Messages to append to the running $messages list.
 */
function neccospeak_chat_tool_messages( array $tool_calls, array $results ) {
	$assistant_calls = array();
	foreach ( $tool_calls as $tc ) {
		$assistant_calls[] = array(
			'id'       => $tc['id'],
			'type'     => 'function',
			'function' => array(
				'name'      => $tc['name'],
				'arguments' => $tc['arguments'],
			),
		);
	}

	$out   = array();
	$out[] = array(
		'role'       => 'assistant',
		'content'    => null,
		'tool_calls' => $assistant_calls,
	);

	foreach ( $tool_calls as $i => $tc ) {
		$r       = isset( $results[ $i ] ) && is_array( $results[ $i ] ) ? $results[ $i ] : array();
		$content = isset( $r['content'] ) && is_string( $r['content'] ) ? $r['content'] : '{"error":"tool_unavailable"}';
		$out[]   = array(
			'role'         => 'tool',
			'tool_call_id' => $tc['id'],
			'content'      => $content,
		);
	}

	return $out;
}

/**
 * Sum `prompt_tokens`/`completion_tokens`/cached tokens across tool-loop
 * rounds (design §5.5 / §2.5 S10). Used for the WP_DEBUG-visible totals
 * only — the per-round usage_daily record itself
 * (neccospeak_chat_usage_record_round() below) records each round separately
 * so a killed process never loses already-billed usage (design §5.1).
 *
 * @param array $acc   Running total.
 * @param array $usage One round's `usage` object from the upstream response.
 * @return array Updated running total.
 */
function neccospeak_chat_usage_merge( array $acc, array $usage ) {
	foreach ( array( 'prompt_tokens', 'completion_tokens' ) as $k ) {
		$acc[ $k ] = ( isset( $acc[ $k ] ) ? (int) $acc[ $k ] : 0 ) + ( isset( $usage[ $k ] ) ? (int) $usage[ $k ] : 0 );
	}
	$cached                  = isset( $usage['prompt_tokens_details']['cached_tokens'] ) ? (int) $usage['prompt_tokens_details']['cached_tokens'] : 0;
	$acc['cached_in_tokens'] = ( isset( $acc['cached_in_tokens'] ) ? (int) $acc['cached_in_tokens'] : 0 ) + $cached;
	return $acc;
}

/**
 * Record one upstream round's usage immediately after its 2xx response
 * (design §5.5). Deliberately NOT deferred to "after the tool loop": a
 * php-fpm request_terminate_timeout kill sends SIGTERM, shutdown functions
 * never run, and anything deferred would be lost while OpenAI has already
 * billed the call. With tools disabled (or includes/tools.php not loaded),
 * the loop below runs exactly one round, so this is called exactly once with
 * arguments byte-for-byte identical to dev.5's inline usage_note() call
 * (INV-1).
 *
 * @param string $model Model id.
 * @param array  $usage The `usage` object from this round's response (may be empty).
 * @param array  $opts  neccospeak_get_options().
 * @return void
 */
function neccospeak_chat_usage_record_round( $model, array $usage, array $opts ) {
	if ( empty( $opts['usage_tracking_enabled'] ) || ! function_exists( 'neccospeak_usage_note' ) ) {
		return;
	}
	$cached = isset( $usage['prompt_tokens_details']['cached_tokens'] ) ? (int) $usage['prompt_tokens_details']['cached_tokens'] : 0;
	neccospeak_usage_note(
		array(
			'kind'              => 'chat',
			'model'             => (string) $model,
			'requests'          => 1,
			'prompt_tokens'     => isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0,
			'completion_tokens' => isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0,
			'cached_in_tokens'  => $cached,
		)
	);
}

/**
 * Lower-bound usage estimate for the SSE path when no usage chunk ever
 * arrived (design §7.3.7 / §2.5 S13) — e.g. a proxy that strips
 * `stream_options`, or a connection that dropped before the final chunk.
 * Recording 0 tokens would silently exempt all SSE traffic from the monthly
 * budget circuit breaker (F39); an explicit `estimated:true` lower bound
 * keeps it in the budget calculation while staying honestly labeled as an
 * estimate, not a measurement.
 *
 * @param array  $messages  The messages array actually sent upstream.
 * @param string $raw_reply Raw (unmasked) assistant text accumulated so far.
 * @return array{prompt_tokens:int,completion_tokens:int,estimated:bool}
 */
function neccospeak_chat_usage_estimate( array $messages, $raw_reply ) {
	$prompt_bytes     = strlen( (string) wp_json_encode( $messages ) );
	$completion_bytes = strlen( (string) $raw_reply );
	return array(
		'prompt_tokens'     => (int) ceil( $prompt_bytes / 4 ),
		'completion_tokens' => (int) ceil( $completion_bytes / 4 ),
		'estimated'         => true,
	);
}

/* =========================================================================
 * Phase 6 G29 — SSE relay + typewriter mode resolution (design §7).
 *
 * neccospeak_sse_upstream_stream() (S17) is the ONLY function in this file
 * that touches cURL directly (design §7.3.2); neccospeak_rest_chat_stream_emit()
 * (S19) is the ONLY function in includes/rest.php allowed to call `exit`
 * (INV-10) — and it calls it only after successfully writing SSE bytes to
 * the client, never as part of an error path (a fallback to the normal JSON
 * response never exits). 0.3.0-dev.42: the second such function,
 * neccospeak_rest_stream_selftest() (S20), was removed with GET
 * /stream-selftest.
 * ====================================================================== */

/**
 * Whether this PHP environment can attempt a real SSE relay at all (design
 * §7.1 / §2.5 S15). PHP_SAPI 'cli' is deliberately excluded — this suite's
 * own tests run under 'cli', so this always evaluates false there; the
 * `neccospeak_sse_supported_override` filter lets tests force a "supported"
 * environment when exercising neccospeak_stream_mode()'s downstream branches
 * (tools_enabled / budget_enabled / model quirk) without needing a real
 * non-CLI SAPI or a live cURL/network stack — the same "injection point"
 * pattern includes/http.php's `neccospeak_http_resolved_ips` filter uses
 * (design §2.1 H3).
 *
 * @return bool
 */
function neccospeak_sse_supported() {
	$real = function_exists( 'curl_init' )
		&& ! ( defined( 'NECCOSPEAK_DISABLE_SSE' ) && NECCOSPEAK_DISABLE_SSE )
		&& 'cli' !== PHP_SAPI;
	return (bool) apply_filters( 'neccospeak_sse_supported_override', $real );
}

/**
 * The stream mode this environment can actually use (design §7.1 / §2.5
 * S14) — a runtime capability decision, not a pass-through of the raw
 * option. Owned by includes/rest.php (impl-chat) but called from
 * includes/frontend-config.php (impl-admin) via a function_exists() guard
 * (design §0.3 D9); untouched (dev.5-identical) when $opts['stream_mode'] is
 * absent or 'off'.
 *
 * @param array $opts neccospeak_get_options().
 * @return string 'off'|'typewriter'|'sse'
 */
function neccospeak_stream_mode( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$mode = isset( $opts['stream_mode'] ) ? (string) $opts['stream_mode'] : 'off';
	if ( ! in_array( $mode, array( 'off', 'typewriter', 'sse' ), true ) ) {
		$mode = 'off';
	}
	if ( 'sse' !== $mode ) {
		return $mode;
	}

	if ( ! neccospeak_sse_supported() ) {
		return 'typewriter';
	}
	// §7.3.6: the tool loop cannot coexist with the "commit to SSE only after
	// upstream returns 200" rule, so tools always win over SSE.
	if ( function_exists( 'neccospeak_tools_enabled' ) && neccospeak_tools_enabled( $opts ) ) {
		return 'typewriter';
	}
	// §7.3.7: usage-estimate-on-SSE is a lower bound, not a measurement; keep
	// the monthly budget circuit breaker on its exact numbers by default.
	if ( ! empty( $opts['budget_enabled'] ) && ! apply_filters( 'neccospeak_sse_allow_with_budget', false ) ) {
		return 'typewriter';
	}
	// A model/proxy that has previously rejected stream_options (§7.3.7 S22)
	// is not retried with SSE again.
	$model = isset( $opts['model'] ) ? (string) $opts['model'] : '';
	if ( function_exists( 'neccospeak_model_quirk_has' ) && neccospeak_model_quirk_has( $model, 'stream_options' ) ) {
		return 'typewriter';
	}
	return 'sse';
}

/**
 * The exact HTTP headers an SSE response needs (design §7.3.3 / §2.5 S1).
 *
 * @return array<string,string>
 */
function neccospeak_sse_headers() {
	return array(
		'Content-Type'           => 'text/event-stream; charset=utf-8',
		'Cache-Control'          => 'no-cache, no-store, must-revalidate, private',
		'Pragma'                 => 'no-cache',
		'Connection'             => 'keep-alive',
		'X-Accel-Buffering'      => 'no',
		'X-Content-Type-Options' => 'nosniff',
	);
}

/**
 * One SSE wire frame (design §7.3.4 / §2.5 S2). A blank `$event` omits the
 * `event:` line entirely (an unnamed "message" event, used for the `d` text
 * deltas). Any embedded newline in `$data` is split across multiple `data:`
 * lines per the SSE spec (defensive — wp_json_encode() never emits a raw
 * newline, but this keeps the function correct even if that ever changes).
 *
 * @param string $event  Event name, or '' for the default unnamed event.
 * @param string $data   Payload (already JSON-encoded by the caller).
 * @return string
 */
function neccospeak_sse_frame( $event, $data ) {
	$out = '';
	if ( '' !== (string) $event ) {
		$out .= 'event: ' . $event . "\n";
	}
	foreach ( explode( "\n", (string) $data ) as $line ) {
		$out .= 'data: ' . $line . "\n";
	}
	return $out . "\n";
}

/**
 * Split an upstream SSE byte buffer into complete frames + a leftover
 * (possibly-incomplete) tail (design §7.3.4 / §2.5 S3). Comment lines
 * (leading ':') and unrecognized fields (id:/retry:) are ignored; a frame
 * with no `data:` line at all (a bare comment/heartbeat frame) contributes
 * nothing to `events`.
 *
 * @param string $buffer Raw bytes (already-parsed prefix + newly-arrived chunk).
 * @return array{events:array<int,array{event:string,data:string}>, rest:string}
 */
function neccospeak_sse_parse( $buffer ) {
	$buffer = str_replace( "\r\n", "\n", (string) $buffer );
	$parts  = explode( "\n\n", $buffer );
	$rest   = (string) array_pop( $parts );

	$events = array();
	foreach ( $parts as $raw_frame ) {
		if ( '' === trim( $raw_frame ) ) {
			continue;
		}
		$event      = '';
		$data_lines = array();
		foreach ( explode( "\n", $raw_frame ) as $line ) {
			if ( '' === $line || ':' === substr( $line, 0, 1 ) ) {
				continue; // Blank line inside a frame, or a comment/heartbeat line.
			}
			if ( 0 === strpos( $line, 'event:' ) ) {
				$event = trim( substr( $line, 6 ) );
				continue;
			}
			if ( 0 === strpos( $line, 'data:' ) ) {
				$data_lines[] = ltrim( substr( $line, 5 ), ' ' );
				continue;
			}
			// Unrecognized field (id:, retry:, ...) — ignored.
		}
		if ( ! empty( $data_lines ) ) {
			$events[] = array(
				'event' => $event,
				'data'  => implode( "\n", $data_lines ),
			);
		}
	}

	return array(
		'events' => $events,
		'rest'   => $rest,
	);
}

/**
 * Extract assistant text / usage / completion from a batch of parsed
 * upstream Chat Completions SSE events (design §7.3.4 / §2.5 S4).
 *
 * @param array $events neccospeak_sse_parse()['events'].
 * @return array{text:string, usage:array, done:bool}
 */
function neccospeak_sse_deltas( array $events ) {
	$text  = '';
	$usage = array();
	$done  = false;

	foreach ( $events as $ev ) {
		$raw = isset( $ev['data'] ) ? (string) $ev['data'] : '';
		if ( '[DONE]' === trim( $raw ) ) {
			$done = true;
			continue;
		}
		$data = json_decode( $raw, true );
		if ( ! is_array( $data ) ) {
			continue;
		}
		if ( isset( $data['usage'] ) && is_array( $data['usage'] ) ) {
			$usage = $data['usage'];
		}
		if ( isset( $data['choices'][0]['delta']['content'] ) && is_string( $data['choices'][0]['delta']['content'] ) ) {
			$text .= $data['choices'][0]['delta']['content'];
		}
	}

	return array(
		'text'  => $text,
		'usage' => $usage,
		'done'  => $done,
	);
}

/**
 * How many trailing bytes of the MASKED text to hold back before emitting
 * (design §7.3.5 / §2.5 S5). A banned word can still be completed by bytes
 * that have not arrived yet, and neccospeak_banned_pattern()'s lookaround
 * (F37) can be decided by one more byte after the longest variant — hence
 * `+1`. Reuses neccospeak_banned_word_variants() so full-width/kana variants
 * are covered exactly like the input gate and the non-stream output mask.
 *
 * @param string[] $words neccospeak_banned_words_list().
 * @return int 0..NECCOSPEAK_SSE_HOLDBACK_MAX
 */
function neccospeak_sse_holdback_bytes( array $words ) {
	return (int) min( NECCOSPEAK_SSE_HOLDBACK_MAX, neccospeak_sse_required_holdback_bytes( $words ) );
}

/**
 * The RAW (uncapped) holdback requirement behind neccospeak_sse_holdback_bytes()
 * above — i.e. how big the holdback window would actually need to be to
 * guarantee the C-16 straddle guarantee for every configured banned word,
 * BEFORE NECCOSPEAK_SSE_HOLDBACK_MAX truncates it down to a wire-sized
 * window. Exists so neccospeak_rest_chat_stream_emit() can detect when a
 * configured word is simply too long for SSE to mask safely (its longest
 * width/kana variant, +1, exceeds the cap) and refuse to stream at all,
 * rather than silently running with an under-sized window that lets the
 * word's own leading bytes reach the wire unmasked before the mask has
 * enough lookahead to catch it (review fix: a holdback shorter than the
 * banned word itself is not a smaller safety margin, it is no safety
 * margin).
 *
 * @param string[] $words neccospeak_banned_words_list().
 * @return int Uncapped required holdback, in bytes. 0 when $words is empty.
 */
function neccospeak_sse_required_holdback_bytes( array $words ) {
	if ( empty( $words ) ) {
		return 0;
	}
	$max = 0;
	foreach ( $words as $w ) {
		foreach ( neccospeak_banned_word_variants( $w ) as $v ) {
			$max = max( $max, strlen( $v ) );
		}
	}
	return $max + 1;
}

/**
 * UTF-8-boundary-safe trim of a trailing incomplete byte sequence.
 *
 * Design §7.3.5's pseudocode calls neccospeak_strcut($slice, strlen($slice))
 * for this job, but neccospeak_strcut()'s own short-circuit
 * (`if (strlen($str) <= $max_bytes) return $str;`, includes/prompt.php) makes
 * that call a no-op whenever $max_bytes === strlen($str) — which is always
 * true at that call site. $slice's end is a raw BYTE offset (design §7.3.5:
 * `$stop = $total - $holdback`), not a character boundary, so an incomplete
 * trailing sequence is a real possibility this function must actually
 * remove. Reimplements prompt.php's own boundary-walk (neccospeak_strcut_fallback())
 * without its length short-circuit, rather than depending on the no-op call.
 *
 * @param string $str Raw bytes, possibly ending mid-character.
 * @return string $str with any incomplete trailing UTF-8 sequence removed.
 */
function neccospeak_sse_utf8_trim_incomplete_tail( $str ) {
	$str = (string) $str;
	$len = strlen( $str );
	if ( 0 === $len ) {
		return '';
	}

	$back  = 0;
	$found = false;
	while ( $back < $len && $back < 4 ) {
		$byte = ord( $str[ $len - 1 - $back ] );
		if ( ( $byte & 0xC0 ) !== 0x80 ) {
			$found = true;
			break;
		}
		++$back;
	}
	if ( ! $found ) {
		return substr( $str, 0, $len - $back ); // Nothing but continuation-byte noise.
	}

	$lead_pos = $len - 1 - $back;
	$lead     = ord( $str[ $lead_pos ] );
	if ( ( $lead & 0x80 ) === 0x00 ) {
		$needed = 0;
	} elseif ( ( $lead & 0xE0 ) === 0xC0 ) {
		$needed = 1;
	} elseif ( ( $lead & 0xF0 ) === 0xE0 ) {
		$needed = 2;
	} elseif ( ( $lead & 0xF8 ) === 0xF0 ) {
		$needed = 3;
	} else {
		$needed = -1;
	}

	if ( $needed < 0 || $back < $needed ) {
		return substr( $str, 0, $lead_pos ); // Invalid lead, or cut mid-character.
	}
	return $str;
}

/**
 * Split the already-masked full text into "safe to send now" / "still held
 * back" (design §7.3.5 / §2.5 S6). Masking is always applied to the WHOLE
 * accumulated raw text first (by the caller); splitting happens on the
 * MASKED string, never the other way around — this is what makes the C-16
 * straddle guarantee hold regardless of how the upstream chunks bytes.
 *
 * @param string $masked_full neccospeak_banned_mask_text( $raw_full, $words ).
 * @param int    $sent        Bytes of $masked_full already written to the wire.
 * @param int    $holdback    neccospeak_sse_holdback_bytes().
 * @return array{emit:string, sent:int} New chunk to emit, and the new $sent total.
 */
function neccospeak_sse_mask_split( $masked_full, $sent, $holdback ) {
	$masked_full = (string) $masked_full;
	$total       = strlen( $masked_full );
	$sent        = max( 0, min( $total, (int) $sent ) );
	$stop        = $total - max( 0, (int) $holdback );

	if ( $stop <= $sent ) {
		return array(
			'emit' => '',
			'sent' => $sent,
		);
	}

	$slice = substr( $masked_full, $sent, $stop - $sent );
	$emit  = neccospeak_sse_utf8_trim_incomplete_tail( $slice );

	return array(
		'emit' => $emit,
		'sent' => $sent + strlen( $emit ),
	);
}

/**
 * Thin, filterable wrapper around ob_get_level() (design §7.3.3 / §2.5 S16).
 * Native ob_* functions cannot be shimmed in tests/bootstrap.php (they are
 * real PHP builtins), so this indirection is the injection point tests use
 * (via the `neccospeak_sse_ob_level_result` filter) to simulate a buffer
 * layer that refuses to shrink, without touching a real output buffer
 * (design §13.2, tests C-42/C-43).
 *
 * @return int
 */
function neccospeak_sse_ob_level() {
	return (int) apply_filters( 'neccospeak_sse_ob_level_result', ob_get_level() );
}

/**
 * Thin, filterable wrapper around ob_end_clean() (design §7.3.3 / §2.5 S16).
 * See neccospeak_sse_ob_level() above for why this indirection exists.
 *
 * @return bool True on success (mirrors ob_end_clean()'s own return contract).
 */
function neccospeak_sse_ob_pop() {
	$override = apply_filters( 'neccospeak_sse_ob_pop_result', null );
	if ( null !== $override ) {
		return (bool) $override;
	}
	return (bool) @ob_end_clean(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- an already-empty/foreign buffer layer legitimately emits a notice here; failure is read from the return value, not surfaced to the visitor.
}

/**
 * Strip whatever output buffers are already active (design §7.3.3 / §2.5
 * S16), WITHOUT sending anything to the wire. clean (not flush): a buffer
 * left over from another plugin/theme (e.g. a compatibility shim that
 * ob_start()s on plugins_loaded) must never be flushed to the client — that
 * would inject HTML in front of the SSE stream and corrupt the wire format.
 * Bounded at 16 iterations so a buffer layer that refuses to shrink
 * (ob_end_clean() returns true but the level doesn't drop) can never spin
 * this forever under a request_terminate_timeout.
 *
 * Factored out of neccospeak_sse_send_headers() (review fix, §7.3.3) so
 * neccospeak_sse_upstream_stream() can run the SAME check BEFORE it ever
 * calls curl_exec() — i.e. before the upstream request that actually gets
 * billed — instead of only discovering an unremovable buffer layer inside
 * the WRITEFUNCTION, by which point a 200 response has already been paid
 * for. neccospeak_sse_send_headers() still calls this too (it is idempotent
 * — once the level is 0 it returns true immediately) so it keeps working
 * correctly as a standalone entry point (neccospeak_rest_stream_selftest()
 * calls it directly, with no upstream call involved at all).
 *
 * @return bool True once the buffer stack is confirmed empty.
 */
function neccospeak_sse_ob_release() {
	$guard = 0;
	while ( neccospeak_sse_ob_level() > 0 && $guard < 16 ) {
		++$guard;
		$before = neccospeak_sse_ob_level();
		if ( ! neccospeak_sse_ob_pop() ) {
			break; // A buffer layer that refuses removal entirely.
		}
		if ( neccospeak_sse_ob_level() >= $before ) {
			break; // Removable, but the level didn't actually drop.
		}
	}
	return 0 === neccospeak_sse_ob_level();
}

/**
 * Disable output buffering/compression and send the SSE headers + a padding
 * preamble (design §7.3.3 / §2.5 S16). Returns false — WITHOUT sending any
 * header or byte — when an output-buffer layer cannot be stripped (e.g. a
 * PHP_OUTPUT_HANDLER_REMOVABLE-less handler some monitoring extensions
 * install); the caller must then fall back to the normal (non-streamed) JSON
 * response (INV-9).
 *
 * @return bool
 */
function neccospeak_sse_send_headers() {
	@ini_set( 'zlib.output_compression', '0' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky -- best-effort; a locked-down host simply keeps its own setting.
	@ini_set( 'output_buffering', '0' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
	@ini_set( 'implicit_flush', '1' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.PHP.IniSet.Risky
	if ( function_exists( 'apache_setenv' ) ) {
		@apache_setenv( 'no-gzip', '1' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- non-Apache SAPIs don't have this function at all; guarded above.
	}

	if ( ! neccospeak_sse_ob_release() ) {
		return false;
	}

	if ( function_exists( 'ob_implicit_flush' ) ) {
		ob_implicit_flush( true );
	}
	if ( function_exists( 'ignore_user_abort' ) ) {
		ignore_user_abort( true ); // So finalize (usage) still runs after the visitor navigates away.
	}
	$max_seconds = (int) apply_filters( 'neccospeak_sse_max_seconds', NECCOSPEAK_SSE_MAX_SECONDS );
	$max_seconds = max( 15, min( 300, $max_seconds ) );
	@set_time_limit( $max_seconds + 30 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- disabled in safe-mode-like hosts; failure is harmless (design §5.1).

	if ( function_exists( 'http_response_code' ) ) {
		http_response_code( 200 );
	}
	foreach ( neccospeak_sse_headers() as $k => $v ) {
		header( $k . ': ' . $v );
	}

	echo ':' . str_repeat( ' ', NECCOSPEAK_SSE_PREAMBLE_BYTES ) . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE comment padding, not HTML.
	if ( function_exists( 'flush' ) ) {
		@flush(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- some SAPIs warn on a no-op flush(); the goal (push bytes now) is best-effort either way.
	}
	return true;
}

/**
 * A fresh neccospeak_sse_upstream_stream() accumulator (design §7.3.7).
 *
 * @return array
 */
function neccospeak_sse_stream_state_new() {
	return array(
		'status'       => 0,
		'headers_sent' => false,
		'sent'         => 0,
		'raw'          => '',
		'sse_rest'     => '',
		'usage'        => array(),
		'started'      => microtime( true ),
		'last_out'     => microtime( true ),
		'aborted'      => false,
		'error'        => '',
	);
}

/**
 * Stream one Chat Completions request via cURL, relaying assistant-text
 * deltas to $on_delta as they arrive (design §7.3.2/§7.3.7 / §2.5 S17).
 *
 * This is the ONLY function in includes/rest.php that touches cURL
 * (design §7.3.2 rule 1): the URL is always the NECCOSPEAK_OPENAI_CHAT_URL
 * constant (rule 2, no URL argument exists on this function), validated
 * immediately before use when includes/http.php is loaded (rule 3), and the
 * transfer is pinned to HTTPS with peer/host verification (rule 4).
 *
 * SSE headers are sent to the visitor via neccospeak_sse_send_headers() the
 * moment the FIRST byte of a 200 upstream body arrives — never earlier
 * (design §7.3.1's core rule: a 429/500/400 upstream response must still be
 * expressible as a normal HTTP status, which is impossible once SSE headers
 * are on the wire).
 *
 * Test seam: $GLOBALS['neccospeak_sse_upstream_stream_override'], when set to
 * a callable, is called instead of touching cURL/the network at all — with
 * $state forwarded so the override can mutate it directly, exactly like this
 * function does (the override's own 3rd parameter must itself be declared
 * `&$state` for that mutation to propagate). This is the same "swap the
 * network for a test double" pattern includes/http.php's
 * `neccospeak_http_resolved_ips` filter uses (design §2.1 H3) — a plain
 * filter isn't used here only because $state must be mutated by reference,
 * which apply_filters() cannot forward.
 *
 * @param array    $payload  Chat Completions payload; caller has already set
 *                            stream=true and stream_options.include_usage=true.
 * @param string   $api_key  Decrypted OpenAI API key.
 * @param array    $state    neccospeak_sse_stream_state_new() (or a
 *                            continuation of one), filled in place.
 * @param callable $on_delta function( string $text_delta ): void — called
 *                            with each new chunk of assistant text, AFTER
 *                            $state['raw'] already includes it and AFTER SSE
 *                            headers have been sent. Owns masking + emitting
 *                            the outgoing frame (design §7.3.5); this
 *                            function only decides WHEN new text exists.
 * @param float|null $deadline microtime(true) deadline shared with the rest
 *                            of neccospeak_rest_chat() (design §5.1/§5.4
 *                            review fix). When given, both the hard cURL
 *                            timeout and the soft in-callback time budget are
 *                            derived from it instead of the independent
 *                            NECCOSPEAK_SSE_MAX_SECONDS window, so an SSE
 *                            attempt can never itself blow past the same wall
 *                            (php-fpm/nginx request_terminate_timeout) the
 *                            budget exists to stay under. Null (e.g. the
 *                            override test seam, or a caller with no shared
 *                            budget) keeps the prior filtered/clamped
 *                            NECCOSPEAK_SSE_MAX_SECONDS behavior.
 * @return array The filled-in $state.
 */
function neccospeak_sse_upstream_stream( array $payload, $api_key, array &$state, callable $on_delta, $deadline = null ) {
	$override = isset( $GLOBALS['neccospeak_sse_upstream_stream_override'] ) ? $GLOBALS['neccospeak_sse_upstream_stream_override'] : null;
	if ( is_callable( $override ) ) {
		return $override( $payload, $api_key, $state, $on_delta );
	}

	if ( ! function_exists( 'curl_init' ) ) {
		$state['error'] = 'curl_unavailable';
		return $state;
	}

	// Review fix (§7.3.3 double-billing): strip/verify the output buffer
	// stack BEFORE the upstream call, not after it has already returned 200
	// and OpenAI has already been billed for it (that used to be the first
	// place this was checked — inside the WRITEFUNCTION below). An
	// unremovable buffer layer now aborts here, before curl_exec() ever
	// runs, so neccospeak_rest_chat_stream_emit() falls back to the ordinary
	// non-stream path having spent nothing on this attempt.
	if ( ! neccospeak_sse_ob_release() ) {
		$state['error'] = 'ob_locked';
		return $state;
	}

	if ( function_exists( 'neccospeak_http_validate_url' ) ) {
		$valid = neccospeak_http_validate_url( NECCOSPEAK_OPENAI_CHAT_URL );
		if ( is_wp_error( $valid ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak SSE: chat endpoint failed URL validation (fixed constant, continuing): ' . $valid->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
	}

	$max_seconds = (int) apply_filters( 'neccospeak_sse_max_seconds', NECCOSPEAK_SSE_MAX_SECONDS );
	$max_seconds = max( 15, min( 300, $max_seconds ) );
	if ( null !== $deadline ) {
		// Review fix (§5.4): never let the SSE session's own ceiling exceed
		// what's actually left of the shared upstream budget — the old
		// `max(15, $max_seconds + 10)` CURLOPT_TIMEOUT was entirely
		// independent of $deadline, so a stalled-but-still-under-60s upstream
		// response plus the non-stream fallback's own MIN_TIMEOUT floor
		// could together exceed the php-fpm/nginx wall this budget exists to
		// stay under.
		$max_seconds = max( 1, min( $max_seconds, (int) floor( (float) $deadline - microtime( true ) ) ) );
	}

	$ch = curl_init( NECCOSPEAK_OPENAI_CHAT_URL ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init -- wp_remote_post() cannot stream a response incrementally (design §7.3.2); this is the plugin's sole, deliberate cURL use, URL-fixed and HTTPS-pinned below.
	curl_setopt_array( // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt_array -- same cURL handle as curl_init() above; bulk-configures the deliberate streaming request.
		$ch,
		array(
			CURLOPT_POST            => true,
			CURLOPT_HTTPHEADER      => array(
				'Authorization: Bearer ' . $api_key,
				'Content-Type: application/json',
			),
			CURLOPT_POSTFIELDS      => wp_json_encode( $payload ),
			CURLOPT_RETURNTRANSFER  => false,
			CURLOPT_FOLLOWLOCATION  => false,
			CURLOPT_PROTOCOLS       => defined( 'CURLPROTO_HTTPS' ) ? CURLPROTO_HTTPS : 2,
			CURLOPT_REDIR_PROTOCOLS => defined( 'CURLPROTO_HTTPS' ) ? CURLPROTO_HTTPS : 2,
			CURLOPT_SSL_VERIFYPEER  => true,
			CURLOPT_SSL_VERIFYHOST  => 2,
			CURLOPT_CONNECTTIMEOUT  => 10,
			CURLOPT_TIMEOUT         => $max_seconds,
		)
	);

	curl_setopt( // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- same cURL handle as curl_init() above; registers the header callback for the deliberate streaming request.
		$ch,
		CURLOPT_HEADERFUNCTION,
		function ( $ch, $line ) use ( &$state ) {
			if ( preg_match( '#^HTTP/\d(?:\.\d)?\s+(\d{3})#', $line, $m ) ) {
				$state['status'] = (int) $m[1];
			}
			return strlen( $line );
		}
	);

	curl_setopt( // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- same cURL handle as curl_init() above; registers the write callback for the deliberate streaming request.
		$ch,
		CURLOPT_WRITEFUNCTION,
		function ( $ch, $chunk ) use ( &$state, $on_delta, $max_seconds ) {
			$n = strlen( $chunk );

			if ( 200 !== (int) $state['status'] ) {
				$state['error'] .= $chunk;
				return ( strlen( $state['error'] ) > 8192 ) ? 0 : $n;
			}

			if ( empty( $state['headers_sent'] ) ) {
				if ( ! neccospeak_sse_send_headers() ) {
					$state['error'] = 'ob_locked';
					return 0;
				}
				$state['headers_sent'] = true;
			}

			if ( function_exists( 'connection_aborted' ) && connection_aborted() ) {
				$state['aborted'] = true;
				return 0;
			}
			if ( microtime( true ) - $state['started'] > $max_seconds ) {
				return 0;
			}

			$parsed            = neccospeak_sse_parse( $state['sse_rest'] . $chunk );
			$state['sse_rest'] = $parsed['rest'];
			$d                 = neccospeak_sse_deltas( $parsed['events'] );
			if ( ! empty( $d['usage'] ) ) {
				$state['usage'] = $d['usage'];
			}
			if ( '' !== $d['text'] ) {
				$state['raw'] .= $d['text'];
				$on_delta( $d['text'] );
				$state['last_out'] = microtime( true );
			}

			if ( microtime( true ) - $state['last_out'] > NECCOSPEAK_SSE_HEARTBEAT_SEC ) {
				echo ": ping\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE comment/heartbeat line, not HTML.
				if ( function_exists( 'flush' ) ) {
					@flush(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				}
				$state['last_out'] = microtime( true );
			}

			return $n;
		}
	);

	curl_exec( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec
	if ( curl_errno( $ch ) && empty( $state['headers_sent'] ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_errno
		$state['error'] = 'curl_error:' . curl_error( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_error
	}
	curl_close( $ch ); // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close

	return $state;
}

/**
 * When a stream attempt failed with an upstream 400 BEFORE any SSE bytes
 * were sent, build a retried payload using the exact same allow-listed
 * G17 logic as the non-stream path (design §7.3.7 / §2.5 S21) — reusing
 * neccospeak_chat_retry_payload() rather than a parallel implementation, so
 * the SSE path can never drift from the non-stream path's retry rules.
 *
 * G17 v2: neccospeak_rest_chat_stream_emit()'s own adaptive retry loop now
 * drives neccospeak_chat_g17_next_adaptation() directly (it needs to track
 * which params were already fixed this request and how many adaptations
 * have run, which this single-shot helper has no state for). This function
 * is kept as the single-step "one 400 -> one candidate payload" primitive —
 * still correct, still exercised directly by tests — rather than removed.
 *
 * @param array $state   neccospeak_sse_upstream_stream() result.
 * @param array $payload The payload that produced $state.
 * @return array|null Adjusted payload to retry with, or null.
 */
function neccospeak_chat_stream_retry( array $state, array $payload ) {
	if ( ! empty( $state['headers_sent'] ) ) {
		return null; // Too late — bytes already on the wire.
	}
	if ( 400 !== (int) ( isset( $state['status'] ) ? $state['status'] : 0 ) ) {
		return null;
	}
	$error_body = isset( $state['error'] ) ? (string) $state['error'] : '';
	return neccospeak_chat_retry_payload( $error_body, $payload );
}

/**
 * Finish an SSE attempt that DID send headers (design §7.3.7 / §2.5 S18):
 * mask, linkify, and resolve usage (falling back to a lower-bound estimate)
 * — identically to the non-stream path's own ordering (G21 mask -> G15
 * linkify -> usage).
 *
 * Deliberately does NOT `exit` (INV-10): only neccospeak_rest_chat_stream_emit()
 * is allowed to. `response` is only
 * ever non-null when $state['headers_sent'] is falsy — i.e. when this is
 * called directly (as tests do) rather than through the normal emit() path,
 * which never calls finalize() before headers are confirmed sent.
 *
 * @param array $state neccospeak_sse_upstream_stream() result.
 * @param array $ctx   {opts, banned, model, messages}.
 * @return array{reply:string, sent:int, usage:array, response:WP_REST_Response|null}
 */
function neccospeak_chat_stream_finalize( array $state, array $ctx ) {
	$opts     = isset( $ctx['opts'] ) && is_array( $ctx['opts'] ) ? $ctx['opts'] : array();
	$banned   = isset( $ctx['banned'] ) && is_array( $ctx['banned'] ) ? $ctx['banned'] : array();
	$model    = isset( $ctx['model'] ) ? (string) $ctx['model'] : '';
	$messages = isset( $ctx['messages'] ) && is_array( $ctx['messages'] ) ? $ctx['messages'] : array();

	$mask_on = ! empty( $banned ) && ! empty( $opts['banned_mask_output'] );

	$full  = isset( $state['raw'] ) ? (string) $state['raw'] : '';
	$reply = $mask_on ? neccospeak_banned_mask_text( $full, $banned ) : $full;
	if ( function_exists( 'neccospeak_page_context_linkify_reply' ) ) {
		$reply = neccospeak_page_context_linkify_reply( $reply );
	}

	$usage = isset( $state['usage'] ) && is_array( $state['usage'] ) ? $state['usage'] : array();
	if ( empty( $usage['prompt_tokens'] ) && empty( $usage['completion_tokens'] ) ) {
		$usage = neccospeak_chat_usage_estimate( $messages, $full );
	}
	if ( ! empty( $opts['usage_tracking_enabled'] ) && function_exists( 'neccospeak_usage_note' ) ) {
		$cached = isset( $usage['prompt_tokens_details']['cached_tokens'] ) ? (int) $usage['prompt_tokens_details']['cached_tokens'] : 0;
		neccospeak_usage_note(
			array(
				'kind'              => 'chat',
				'model'             => $model,
				'requests'          => 1,
				'prompt_tokens'     => isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0,
				'completion_tokens' => isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0,
				'cached_in_tokens'  => $cached,
				'estimated'         => ! empty( $usage['estimated'] ),
			)
		);
	}

	// 2026-08 撤去記録: ここにあった neccospeak_maybe_log_conversation() 呼び出し
	// (会話ログへの記録)は、AI応答評価と同様に無料版から提供しない製品判断により
	// 会話ログ機能ごと削除した。usage 記録(上記)はそのまま残っている。
	$response = null;
	if ( empty( $state['headers_sent'] ) ) {
		$response = new WP_REST_Response( array( 'error' => __( 'Upstream request failed.', 'neccospeak' ) ), 502 );
	}

	return array(
		'reply'    => $reply,
		'sent'     => isset( $state['sent'] ) ? (int) $state['sent'] : 0,
		'usage'    => $usage,
		'response' => $response,
	);
}

/**
 * The SSE send-out body for POST /chat?stream=1 (design §7.3 / §2.5 S19).
 * The one function in this file allowed to `exit` (INV-10) — and it
 * only does so after a `done` (or the stream genuinely started and then hit
 * trouble) frame has actually been written.
 *
 * If the FIRST attempt never manages to send SSE headers at all (§7.3.1: a
 * non-2xx upstream response, cURL/validation failure, or a locked output
 * buffer), this function does NOT fabricate an error response itself.
 * Instead — generalizing design §7.3.7's dedicated "400 -> adapt (possibly
 * more than once, G17 v2) -> if still failing, run the full non-stream path"
 * rule to every pre-header failure, not just 400 (the safer, INV-9-consistent
 * reading: a visitor should get their normal answer via the ordinary
 * tool-loop path rather than a bare transport error whenever SSE could not
 * even start) — it returns `null` so the caller (neccospeak_rest_chat())
 * falls through to the regular non-stream flow, complete with its own G17
 * adaptive retry loop.
 *
 * @param array      $payload  Chat Completions payload (stream flags NOT yet set).
 * @param string     $api_key  Decrypted OpenAI API key.
 * @param string     $model    Resolved model id.
 * @param array      $messages Messages actually sent (for the usage estimate fallback).
 * @param array      $opts     neccospeak_get_options().
 * @param float|null $deadline microtime(true) deadline shared with
 *                              neccospeak_rest_chat() (design §5.1/§5.4
 *                              review fix) — see neccospeak_sse_upstream_stream().
 * @return WP_REST_Response|null Null means "fall through to the non-stream path".
 */
function neccospeak_rest_chat_stream_emit( array $payload, $api_key, $model, array $messages, array $opts, $deadline = null ) {
	$banned  = neccospeak_banned_words_list( $opts );
	$mask_on = ! empty( $banned ) && ! empty( $opts['banned_mask_output'] );

	// Review fix (§7.3.5 / C-16): if the longest configured banned-word
	// variant needs a bigger holdback window than SSE can provide,
	// neccospeak_sse_holdback_bytes() would silently CAP it below what's
	// actually required — letting the word's own leading bytes reach the
	// wire, unmasked, before the mask has enough lookahead to catch them.
	// Never run that risk: fall back to the non-stream path instead, which
	// masks the WHOLE accumulated reply in one pass and cannot straddle at
	// all.
	if ( $mask_on && neccospeak_sse_required_holdback_bytes( $banned ) > NECCOSPEAK_SSE_HOLDBACK_MAX ) {
		return null;
	}
	$holdback = $mask_on ? neccospeak_sse_holdback_bytes( $banned ) : 0;

	$payload['stream']         = true;
	$payload['stream_options'] = array( 'include_usage' => true );

	$state    = neccospeak_sse_stream_state_new();
	$on_delta = function ( $text_delta ) use ( &$state, $mask_on, $banned, $holdback ) {
		$masked_full   = $mask_on ? neccospeak_banned_mask_text( $state['raw'], $banned ) : $state['raw'];
		$split         = neccospeak_sse_mask_split( $masked_full, $state['sent'], $holdback );
		$state['sent'] = $split['sent'];
		if ( '' !== $split['emit'] ) {
			echo neccospeak_sse_frame( '', wp_json_encode( array( 'd' => $split['emit'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE wire frame, not HTML.
			if ( function_exists( 'flush' ) ) {
				@flush(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
			}
		}
	};

	// Review fix (§5.4): never start (or retry) an upstream call with less
	// than NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT left on the shared budget —
	// the non-stream tool loop's own timeout floors at that minimum
	// regardless of how little is actually left, so starting a fresh call
	// this close to (or past) $deadline would blow straight through the
	// php-fpm/nginx wall the budget exists to stay under. Fail the same way
	// dev.5 always did on a genuine upstream timeout, instead of trying (and
	// likely failing even harder) with an unrealistic time slice.
	$out_of_budget = ( null !== $deadline )
		&& ( ( (float) $deadline - microtime( true ) ) < NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT );
	if ( $out_of_budget ) {
		return new WP_REST_Response( array( 'error' => __( 'Upstream request failed.', 'neccospeak' ) ), 502 );
	}

	$state = neccospeak_sse_upstream_stream( $payload, $api_key, $state, $on_delta, $deadline );

	// G17 v2 (generalizes design §7.3.7 rows 1/3): a pre-header 400 drives a
	// bounded, ADAPTIVE retry loop — one allow-listed param adjusted per
	// iteration, never the same param twice this request, each adaptation
	// remembered via neccospeak_remember_model_quirk() before the retry — the
	// same rules as the non-stream path's own loop (neccospeak_rest_chat()),
	// via the shared decision helper neccospeak_chat_g17_next_adaptation() so
	// the two can't drift apart. Stops as soon as headers are sent, the model
	// stops 400ing, or NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS is reached; each
	// iteration re-checks the shared time budget before spending it on
	// another upstream call, and rebuilds SSE state from scratch, exactly as
	// this call site always did for its single retry.
	$g17_adaptations = 0;
	$g17_fixed       = array();
	while ( empty( $state['headers_sent'] ) && 400 === (int) $state['status'] ) {
		$next = neccospeak_chat_g17_next_adaptation( (string) $state['error'], $payload, $g17_fixed, $g17_adaptations );
		if ( null === $next ) {
			break;
		}
		neccospeak_remember_model_quirk( $model, $next['param'] );
		$g17_fixed[ $next['param'] ] = true;
		++$g17_adaptations;
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak chat G17 SSE adaptation #' . $g17_adaptations . ': model=' . $model . ' param=' . $next['param'] . ' — retrying with adjusted payload' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}

		$retry_out_of_budget = ( null !== $deadline )
			&& ( ( (float) $deadline - microtime( true ) ) < NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT );
		if ( $retry_out_of_budget ) {
			return new WP_REST_Response( array( 'error' => __( 'Upstream request failed.', 'neccospeak' ) ), 502 );
		}

		$payload = $next['payload'];
		$state   = neccospeak_sse_stream_state_new();
		$state   = neccospeak_sse_upstream_stream( $payload, $api_key, $state, $on_delta, $deadline );
	}

	if ( empty( $state['headers_sent'] ) ) {
		return null; // Caller runs the full non-stream path (design §7.3.7 row 2).
	}

	$result = neccospeak_chat_stream_finalize(
		$state,
		array(
			'opts'     => $opts,
			'banned'   => $banned,
			'model'    => $model,
			'messages' => $messages,
		)
	);

	echo neccospeak_sse_frame( 'done', wp_json_encode( array( 'reply' => $result['reply'] ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE wire frame, not HTML.
	if ( function_exists( 'flush' ) ) {
		@flush(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
	}
	exit; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_alter, Squiz.PHP.DiscouragedFunctions.Discouraged, WordPress.PHP.DevelopmentFunctions -- INV-10: the one function in this file allowed to exit (design §0.2 INV-10). SSE bypasses WP REST's own response serialization by construction (§7.3), and this line is only reached after a `done` frame was actually written.
}

/* -------------------------------------------------------------------------
 * Phase 5 telemetry endpoints (design phase5 §6): /events (G31),
 * /usage-report (G3(b)).
 *
 * 2026-08 撤去記録: この節にあった POST /feedback (G19) — ルート登録・
 * neccospeak_rest_feedback()・NECCOSPEAK_FEEDBACK_*_MAX_BYTES 定数・
 * public ルート allow-list の項目 — は、AI応答評価機能を無料版から提供しない
 * 製品判断により削除した。共有ヘルパー
 * （neccospeak_rest_telemetry_preflight() 等）は /events /usage-report が
 * 引き続き使うためそのまま残している。
 *
 * 2026-08 撤去記録: この節末尾にあった G41 会話ログ機能(neccospeak_maybe_log_conversation()・
 * neccospeak_rest_chat() 内の3呼び出し箇所)は、会話ログを無料版から提供しない
 * 製品判断により削除した。$last_user_text の算出自体は RAG クエリが引き続き
 * 使うため neccospeak_rest_chat() 内に残っている(下記参照)。
 *
 * Every includes/db.php call in this section is function_exists()-guarded
 * per the design §1.3 cross-agent contract (impl-db owns db.php; this file
 * must degrade to a safe no-op if it is not loaded). Every route also
 * degrades to 200 {"ok":false} when its own feature flag is off or the DB
 * layer is not ready, never 403/404 (design §6.1) — a disabled feature must
 * never look like a broken endpoint to the widget's retry logic.
 * ---------------------------------------------------------------------- */

/**
 * Shared preflight for the Phase 5 telemetry endpoints (design §6.1).
 *
 * Distinct from neccospeak_rest_preflight() (used by /chat, /realtime-token):
 * these routes never touch OpenAI, so there is no API key to decrypt and no
 * `enabled`/circuit-breaker gate — only the same-origin guard, this route's
 * own rate limits, and DB readiness.
 *
 * @param WP_REST_Request $req         Request.
 * @param string          $bucket      Endpoint bucket ('fb'|'ev'|'ur') for rate-limit key isolation.
 * @param int             $ip_limit    Per-IP per-minute limit.
 * @param int             $daily_limit Global per-day limit.
 * @return true|WP_REST_Response True when allowed, an error/degraded response otherwise.
 */
function neccospeak_rest_telemetry_preflight( $req, $bucket, $ip_limit, $daily_limit ) {
	// Same anonymity policy as neccospeak_rest_preflight() (design §0.2.3-b):
	// these are public visitor routes with no authenticated-only behavior.
	wp_set_current_user( 0 );

	$guard = neccospeak_rest_guard( $req );
	if ( $guard instanceof WP_REST_Response ) {
		return $guard;
	}

	if ( ! neccospeak_check_ip_rate_limit( $bucket, (int) $ip_limit ) ) {
		return neccospeak_rate_limited_response( MINUTE_IN_SECONDS );
	}
	if ( ! neccospeak_check_global_rate_limit( $bucket, 'daily', (int) $daily_limit, DAY_IN_SECONDS ) ) {
		return neccospeak_rate_limited_response( HOUR_IN_SECONDS );
	}

	if ( ! function_exists( 'neccospeak_db_ready' ) || ! neccospeak_db_ready() ) {
		// Quietly degrade rather than error — the DB layer being unready
		// (not yet upgraded, or includes/db.php not loaded) must never look
		// like a visitor-facing failure (design §6.1).
		return new WP_REST_Response( array( 'ok' => false ), 200 );
	}

	return true;
}

/**
 * Derived daily cap for /events (design §6.3, review issue #22): scales with
 * the site's own `rate_limit_global_daily` (chat) setting instead of a
 * hardcoded, invisible 20,000, so a site that raised its chat traffic
 * expectations gets a proportionally raised analytics cap automatically. At
 * the option default (1000) this evaluates to exactly 20,000 — unchanged
 * behavior from the pre-derivation design.
 *
 * @return int
 */
function neccospeak_events_daily_cap() {
	$base = (int) neccospeak_option( 'rate_limit_global_daily', 1000 );
	$cap  = ( $base > 0 ) ? $base * NECCOSPEAK_EVENTS_PER_SESSION : 20000;
	$cap  = max( 1000, min( 500000, $cap ) );
	return (int) apply_filters( 'neccospeak_events_daily_cap', $cap );
}

/**
 * Record (once per UTC day) that /events hit its global daily cap, so the
 * Logs tab's Analytics view can warn that the day's chart is incomplete
 * after that point (design §6.3 (2)). Deliberately a single runtime write
 * per day (guarded by comparing `events_day` to today) — this is called from
 * a hot, unauthenticated path, and INV-1's "bounded write frequency" spirit
 * applies here even though INV-1 itself only names `usage_daily`/budget sync.
 *
 * @return void
 */
function neccospeak_events_capped_note() {
	if ( ! function_exists( 'neccospeak_runtime_get' ) || ! function_exists( 'neccospeak_runtime_set' ) ) {
		return;
	}
	$today = function_exists( 'neccospeak_db_today' ) ? neccospeak_db_today() : gmdate( 'Y-m-d' );
	$rt    = neccospeak_runtime_get();
	if ( isset( $rt['events_day'] ) && $today === $rt['events_day'] ) {
		return; // Already recorded today.
	}
	neccospeak_runtime_set(
		array(
			'events_day'       => $today,
			'events_capped_at' => time(),
		)
	);
}

/**
 * The full, allow-listed set of G31 analytics event names (design §4.3's
 * `analytics_events` enum_set — kept in sync with that option's `allowed`
 * list, since impl-admin owns field_specs but this file owns what actually
 * gets accepted at the endpoint).
 *
 * @param array $opts neccospeak_get_options().
 * @return string[] The subset of the canonical 7 the admin has enabled for
 *                   collection (canonical order). Missing/malformed option
 *                   defaults to "all 7 enabled", matching neccospeak_pii_types()'s
 *                   same "absent key -> documented default" convention.
 */
function neccospeak_events_allowed_names( $opts ) {
	$canonical = array( 'open', 'close', 'message_sent', 'message_received', 'voice_start', 'voice_end', 'cta_click' );
	$opts      = is_array( $opts ) ? $opts : array();
	$raw       = ( isset( $opts['analytics_events'] ) && is_array( $opts['analytics_events'] ) ) ? $opts['analytics_events'] : $canonical;

	$out = array();
	foreach ( $canonical as $name ) {
		if ( in_array( $name, $raw, true ) ) {
			$out[] = $name;
		}
	}
	return $out;
}

/**
 * Extract event names from a decoded /events body, clamped to
 * NECCOSPEAK_EVENTS_BATCH_MAX BEFORE any allow-list filtering (design §6.3:
 * "an oversized array is truncated to the first 20, not rejected with 400").
 * Only the documented `{"n": "..."}` shape is accepted per event (design
 * §6.3's payload table) — a malformed entry is skipped, not fatal.
 *
 * @param mixed $body Decoded JSON body.
 * @return string[] Event names as sent by the client, NOT yet allow-list-filtered.
 */
function neccospeak_events_extract_names( $body ) {
	$body   = is_array( $body ) ? $body : array();
	$events = ( isset( $body['events'] ) && is_array( $body['events'] ) ) ? $body['events'] : array();
	$events = array_slice( $events, 0, NECCOSPEAK_EVENTS_BATCH_MAX );

	$names = array();
	foreach ( $events as $e ) {
		if ( is_array( $e ) && isset( $e['n'] ) && is_string( $e['n'] ) && '' !== $e['n'] ) {
			$names[] = $e['n'];
		}
	}
	return $names;
}

/**
 * Intersect client-sent event names with the server-held allow-list — "the
 * server has final say" (design §6.3: "サーバ側が最終権限").
 *
 * @param string[] $names   From neccospeak_events_extract_names().
 * @param string[] $allowed From neccospeak_events_allowed_names().
 * @return string[] Names present in both, in $names order.
 */
function neccospeak_events_filter_allowed( array $names, array $allowed ) {
	$out = array();
	foreach ( $names as $n ) {
		if ( in_array( $n, $allowed, true ) ) {
			$out[] = $n;
		}
	}
	return $out;
}

/**
 * POST /events handler (G31, design §6.3).
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function neccospeak_rest_events( $req ) {
	// Same shape as neccospeak_rest_telemetry_preflight(), but split out
	// inline so the daily-cap rejection specifically (not the per-IP one)
	// can trigger neccospeak_events_capped_note() (design §6.3 (2)) — the
	// generic helper has no way to report which of its two rate-limit checks
	// was the one that failed.
	//
	// The global daily cap (neccospeak_events_daily_cap()) is documented, and
	// derived (design §6.3), as an EVENT count — "cap = sessions ×
	// NECCOSPEAK_EVENTS_PER_SESSION" — and the admin UI's "Today's limit is %s
	// events" string says the same. It must therefore be checked (and
	// consumed) against the number of events this request actually gets
	// accepted, NOT once per request: a single request can carry up to
	// NECCOSPEAK_EVENTS_BATCH_MAX (20) events, so counting requests here let
	// the real row-insertion rate run up to 20x the documented cap (review:
	// "high" — disk-exhaustion via the daily cap being a request cap in
	// disguise). The check therefore happens AFTER $names is known, and
	// consumes exactly count( $names ) units in one atomic call.
	//
	// Feature gate + DB-readiness FIRST, before any side effect (design
	// review: rate limiting must not fire — and no rate-limit-counter DB
	// write must happen — on a site where analytics_enabled is off; INV-1's
	// "no new side effects by default" guarantee). Both checks below are
	// reads only, so they are safe ahead of
	// wp_set_current_user()/guard/rate-limiting.
	$opts = neccospeak_get_options();
	if ( empty( $opts['analytics_enabled'] ) ) {
		return new WP_REST_Response( array( 'ok' => false ), 200 );
	}
	if ( ! function_exists( 'neccospeak_db_ready' ) || ! neccospeak_db_ready() ) {
		return new WP_REST_Response( array( 'ok' => false ), 200 );
	}

	wp_set_current_user( 0 );

	$guard = neccospeak_rest_guard( $req );
	if ( $guard instanceof WP_REST_Response ) {
		return $guard;
	}

	if ( ! neccospeak_check_ip_rate_limit( 'ev', 30 ) ) {
		return neccospeak_rate_limited_response( MINUTE_IN_SECONDS );
	}

	// design §6.3: "ボディ全体が 8KB を超えたら 413". Measured on the raw body
	// so a request that never even parses as valid JSON is still bounded.
	$raw_body = method_exists( $req, 'get_body' ) ? (string) $req->get_body() : '';
	if ( strlen( $raw_body ) > NECCOSPEAK_EVENTS_MAX_BODY_BYTES ) {
		return new WP_REST_Response(
			array(
				'ok'    => false,
				'error' => __( 'Payload too large.', 'neccospeak' ),
			),
			413
		);
	}

	$body = $req->get_json_params();
	$body = is_array( $body ) ? $body : array();
	$sid  = isset( $body['sid'] ) ? (string) $body['sid'] : '';

	// design §6.3: client-sent timestamps are never read/trusted here —
	// created_at is always server time (neccospeak_db_add_events() sets it).
	$names = neccospeak_events_extract_names( $body );
	$names = neccospeak_events_filter_allowed( $names, neccospeak_events_allowed_names( $opts ) );

	$n = 0;
	if ( ! empty( $names ) ) {
		if ( ! neccospeak_check_global_rate_limit( 'ev', 'daily', neccospeak_events_daily_cap(), DAY_IN_SECONDS, count( $names ) ) ) {
			neccospeak_events_capped_note();
			return neccospeak_rate_limited_response( HOUR_IN_SECONDS );
		}

		if ( function_exists( 'neccospeak_db_add_events' ) ) {
			$n = neccospeak_db_add_events( $sid, $names );
		}
	}

	return new WP_REST_Response(
		array(
			'ok' => true,
			'n'  => $n,
		),
		200
	);
}

// 2026-08 撤去記録: この位置にあった neccospeak_maybe_log_conversation()
// (G41: 会話ターンの永続化。INV-13 の固定 PII マスキングも含む) は、会話ログ
// 機能ごと削除した(理由は上のセクション冒頭コメント参照)。

/**
 * POST /chat handler. Relays to OpenAI Chat Completions.
 *
 * Phase 6 (design §5.3/§7.3): runs a bounded function-calling tool loop
 * (G16 — at most 1 + neccospeak_tools_max_rounds() upstream calls, exactly 1
 * when tools are disabled or includes/tools.php is not loaded, matching
 * dev.5 byte-for-byte per INV-1), feeds the page-context/RAG request-scoped
 * stores (G15/G26, design §2.8) around neccospeak_compose_system_prompt(),
 * and — when `?stream=1` and the environment/options allow it — relays
 * OpenAI's own SSE stream to the browser (G29, §7.3) instead of returning
 * one JSON body.
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function neccospeak_rest_chat( $req ) {
	// Phase 6 G16 (design §5.8): neccospeak_get_options() is fetched before
	// preflight (rather than after, as dev.5 did) so neccospeak_rest_preflight()
	// can size the chat rate-limit consumption to 1 + max_rounds when tools
	// are enabled. Pure/side-effect-free either way.
	$opts = neccospeak_get_options();

	$pre = neccospeak_rest_preflight( $req, 'chat', $opts );
	if ( $pre instanceof WP_REST_Response || is_wp_error( $pre ) ) {
		return $pre;
	}
	$api_key = $pre;

	$body = $req->get_json_params();
	if ( ! is_array( $body ) ) {
		$body = array();
	}

	// ---- Phase 6 G15 (design §2.5 順1): resolve+validate the client-claimed
	//      post_id server-side (INV-5) right after preflight/body parsing, so
	//      it is available for the page-context request store below AND for
	//      includes/tools.php's get_page_info tool (which reuses the same
	//      resolver). function_exists()-guarded: includes/page-context.php
	//      may not be loaded (or may not exist yet). ----
	$post_id = function_exists( 'neccospeak_page_context_resolve' )
		? neccospeak_page_context_resolve( isset( $body['post_id'] ) ? $body['post_id'] : 0 )
		: 0;

	// ---- G21 (a): banned-word input gate. Runs on sanitized, count-clamped
	//      user text — i.e. exactly what neccospeak_build_messages() below is
	//      about to send, for BOTH accepted body shapes (neccospeak_extract_user_texts()
	//      shares its normalize/sanitize/clamp pipeline with
	//      neccospeak_build_messages() precisely so the two can never
	//      disagree) — BEFORE any PII masking, over EVERY role==='user'
	//      message (design rev.2 §7.4, review C4) — /chat is an anonymous
	//      public route, so a caller can hide a banned word behind a harmless
	//      final message; scanning only the last user turn would let it
	//      through untouched. No upstream call is made at all when this hits,
	//      so a block costs nothing (it still consumes this site's rate
	//      limits — see neccospeak_rest_preflight() above, and design rev.2
	//      §7.4's explicit C17 trade-off note). ----
	$banned = neccospeak_banned_words_list( $opts );
	if ( ! empty( $banned ) && ! empty( $opts['banned_block_input'] ) ) {
		foreach ( neccospeak_extract_user_texts( $body ) as $user_text ) {
			if ( '' !== neccospeak_banned_match( $user_text, $banned ) ) {
				// 2026-08 撤去記録: ここにあった neccospeak_maybe_log_conversation()
				// 呼び出し(ブロックされたターンも会話ログに記録)は、会話ログ機能
				// ごと削除した。
				return new WP_REST_Response(
					array(
						'reply'   => neccospeak_banned_blocked_reply( $opts ),
						'blocked' => true,
					),
					200
				);
			}
		}
	}

	$messages = neccospeak_build_messages( $body, $opts ); // G20 (a) PII masking happens inside, user-only.
	if ( empty( $messages ) ) {
		return new WP_REST_Response(
			array( 'error' => __( 'No message provided.', 'neccospeak' ) ),
			400
		);
	}

	// Phase 6 G26 (design §2.8): the last role==='user' message from the
	// already-normalized/sanitized/PII-masked list, captured BEFORE the
	// server-generated system prompt is unshifted below — this is the RAG
	// query text fed to neccospeak_rag_request() a few lines down. (2026-08
	// 撤去記録: this used to also feed the now-removed G41 conversation-log
	// helper — the computation itself stays because RAG still needs it.)
	$last_user_text = '';
	foreach ( $messages as $m ) {
		if ( 'user' === $m['role'] ) {
			$last_user_text = $m['content'];
		}
	}

	// ---- Phase 6 G15/G26 (design §2.5 順3-5 / §2.8): feed the request-scoped
	//      page-context/RAG stores just before composing the system prompt,
	//      and ALWAYS clear them again immediately after — success or
	//      exception — so a later request on the same PHP worker process
	//      (persistent workers, opcache, etc.) can never observe a stale
	//      post_id/query. Both calls are function_exists()-guarded: neither
	//      includes/page-context.php nor includes/rag.php need to be loaded
	//      for this to behave exactly like dev.5 (INV-1 — an unset store is
	//      each filter's own documented "do nothing" default, design §2.8). ----
	try {
		if ( function_exists( 'neccospeak_page_context_request' ) ) {
			neccospeak_page_context_request( array( 'post_id' => $post_id ) );
		}
		if ( function_exists( 'neccospeak_rag_request' ) ) {
			neccospeak_rag_request( array( 'query' => $last_user_text ) );
		}
		// Server-generated system prompt prepended (client never supplies system).
		$system_prompt = neccospeak_compose_system_prompt( $opts, 'text' );
	} finally {
		if ( function_exists( 'neccospeak_page_context_request' ) ) {
			neccospeak_page_context_request( array( 'post_id' => 0 ) );
		}
		if ( function_exists( 'neccospeak_rag_request' ) ) {
			neccospeak_rag_request( array( 'query' => '' ) );
		}
	}

	if ( '' !== trim( $system_prompt ) ) {
		array_unshift(
			$messages,
			array(
				'role'    => 'system',
				'content' => $system_prompt,
			)
		);
	}

	$model = isset( $opts['model'] ) ? (string) $opts['model'] : '';
	if ( '' === trim( $model ) ) {
		$model = 'gpt-4o-mini';
	}

	// ---- Phase 6 G16 (design §5.1/§5.3): the tool-loop budget. With
	//      includes/tools.php not loaded (or tools_enabled=false), $max_rounds
	//      is 0 and $tools stays empty, so the loop below runs exactly once
	//      with a payload byte-for-byte identical to dev.5's (INV-1). ----
	//      Review fix: neccospeak_tools_max_rounds() itself does not consult
	//      $opts at all (it always returns the configured/filtered round
	//      count regardless of tools_enabled), so gating on tools_enabled()
	//      MUST happen here, exactly like neccospeak_rest_preflight()'s own
	//      rate-limit amount already does a few hundred lines up — otherwise
	//      $max_rounds stays >0 with tools OFF, which starves
	//      neccospeak_chat_upstream_timeout() of budget it will never spend
	//      on a tool round that can never happen (INV-1 regression: with
	//      tools off, timeout drops from dev.5's fixed 60s to ~20s). ----
	$budget       = (int) apply_filters( 'neccospeak_chat_upstream_budget', NECCOSPEAK_CHAT_UPSTREAM_BUDGET );
	$budget       = max( 15, min( 300, $budget ) );
	$deadline     = microtime( true ) + $budget;
	$tools_are_on = function_exists( 'neccospeak_tools_enabled' ) && neccospeak_tools_enabled( $opts );
	$max_rounds   = ( $tools_are_on && function_exists( 'neccospeak_tools_max_rounds' ) ) ? (int) neccospeak_tools_max_rounds() : 0;
	if ( $max_rounds > 0 ) {
		@set_time_limit( $budget + 30 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- disabled in safe-mode-like hosts; failure is harmless (design §5.1).
	}
	$tools = ( $max_rounds > 0 && function_exists( 'neccospeak_tools_specs' ) ) ? neccospeak_tools_specs( $opts ) : array();

	// ---- Phase 6 G29 (design §7.3.1/§7.3.6): the SSE branch. Only attempted
	//      when the caller asked for it AND neccospeak_stream_mode() (which
	//      already accounts for tools_enabled/budget_enabled/model quirks)
	//      resolves to 'sse'. neccospeak_rest_chat_stream_emit() either exits
	//      after writing a `done`/streamed response, or returns null to mean
	//      "could not stream — fall through to the ordinary tool loop below,
	//      unchanged" (design §7.3.7 row 2, generalized to every pre-header
	//      failure, not just a 400). ----
	$want_stream = method_exists( $req, 'get_param' ) && ( '1' === (string) $req->get_param( 'stream' ) );
	$can_stream  = $want_stream && empty( $tools ) && ( 'sse' === neccospeak_stream_mode( $opts ) );
	if ( $can_stream ) {
		$stream_payload = neccospeak_build_chat_payload( $model, $messages, $opts );
		$stream_result  = neccospeak_rest_chat_stream_emit( $stream_payload, $api_key, $model, $messages, $opts, $deadline );
		if ( $stream_result instanceof WP_REST_Response ) {
			return $stream_result;
		}
		// null: fall through to the non-stream tool loop below, from round 0.
	}

	// ---- Phase 6 G16 (design §5.3): the tool-calling state machine. ----
	$round          = 0;
	$data           = null;
	$last_good_data = null;
	$degraded       = ''; // '' | 'exec' | 'rounds' | 'budget' | 'upstream'
	$exec_ms        = 0;

	while ( true ) {
		$rounds_left = max( 0, $max_rounds - $round );
		$timeout     = neccospeak_chat_upstream_timeout( $deadline, $rounds_left );

		$payload = neccospeak_build_chat_payload( $model, $messages, $opts );
		// Tools are declared only while a round remains: dropping them on the
		// final allowed call is the most compatible way to tell the model
		// "no more tools — answer now" (design §5.3, more portable than
		// tool_choice:'none' across providers/models).
		if ( ! empty( $tools ) && $rounds_left > 0 ) {
			$payload['tools']       = $tools;
			$payload['tool_choice'] = 'auto';
		}

		$response = neccospeak_openai_chat_request( $payload, $api_key, $timeout );

		// G17 v2: a bounded, ADAPTIVE retry loop when OpenAI's 400 names a
		// parameter this model doesn't accept. Only on the FIRST round — each
		// adaptation is remembered via neccospeak_remember_model_quirk() before
		// the retry, and applied automatically to every later round's payload
		// via neccospeak_build_chat_payload(), so a param fixed here cannot
		// recur later in this request OR in any future request for this model.
		// A single retry is not always enough: some models (e.g. the GPT-5
		// family) reject max_tokens AND temperature within the same request,
		// so this keeps adapting — one param per iteration, never the same
		// param twice this request — until it succeeds, the model stops
		// returning a 400, or NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS is reached.
		if ( 0 === $round ) {
			$g17_adaptations = 0;
			$g17_fixed       = array();
			while ( ! is_wp_error( $response ) && 400 === (int) wp_remote_retrieve_response_code( $response ) ) {
				$raw  = wp_remote_retrieve_body( $response );
				$next = neccospeak_chat_g17_next_adaptation( $raw, $payload, $g17_fixed, $g17_adaptations );
				if ( null === $next ) {
					break;
				}
				neccospeak_remember_model_quirk( $model, $next['param'] );
				$g17_fixed[ $next['param'] ] = true;
				++$g17_adaptations;
				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					error_log( 'NeccoSpeak chat G17 adaptation #' . $g17_adaptations . ': model=' . $model . ' param=' . $next['param'] . ' — retrying with adjusted payload' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
				}

				// レビュー修正: このループはかつて、ループ開始時に一度だけ算出
				// した $timeout をそのまま毎回使い回し、$deadline の残り予算を
				// 一度も確認していなかった。適応が NECCOSPEAK_CHAT_G17_MAX_ADAPTATIONS
				// 回まで連鎖すると最大4回の逐次ブロッキング呼び出しが走り、その
				// 合計が $deadline や関数冒頭の @set_time_limit() を突き抜けて
				// php-fpm/ゲートウェイ側のタイムアウトに当たり得た — その場合、
				// この関数が丁寧に返すはずのエラーではなく、素の 500/504 になる。
				// ストリーミング側（neccospeak_rest_chat_stream_emit() の同名
				// ループ）は再試行の直前に必ず残り予算を確認しており、これは
				// その非ストリーミング側の対になる処理。ここで return せず
				// break するのは、$response を直前の 400 のままループの外に
				// 残し、既存のエラーマッピング（下の
				// ! neccospeak_http_2xx( $response ) 分岐、502）に従来通り
				// 処理させるため — エラー文言・振り分けは変えない。
				//
				// なお $deadline はこの関数の冒頭（tools 分岐より前）で
				// microtime(true) から無条件に算出される float であり、この
				// ループに到達するまで再代入されない（ストリーミング側の
				// $deadline 引数のようにヌル許容ではない）ため、null チェックは
				// 不要。
				if ( ( $deadline - microtime( true ) ) < NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT ) {
					break;
				}
				// 使い回しをやめ、残り予算から $timeout を再算出する。
				$timeout  = neccospeak_chat_upstream_timeout( $deadline, $rounds_left );
				$payload  = $next['payload'];
				$response = neccospeak_openai_chat_request( $payload, $api_key, $timeout );
			}
		}

		if ( is_wp_error( $response ) || ! neccospeak_http_2xx( $response ) ) {
			if ( 0 === $round ) {
				// Unchanged dev.5 error mapping — never leak internal details.
				if ( is_wp_error( $response ) ) {
					if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
						error_log( 'NeccoSpeak chat transport error: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
					}
					return new WP_REST_Response(
						array( 'error' => __( 'Upstream request failed.', 'neccospeak' ) ),
						502
					);
				}
				$code = (int) wp_remote_retrieve_response_code( $response );
				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					error_log( 'NeccoSpeak chat upstream HTTP ' . $code . ': ' . neccospeak_log_trunc( wp_remote_retrieve_body( $response ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
				}
				$out_code = ( 429 === $code ) ? 429 : 502;
				return new WP_REST_Response(
					array( 'error' => __( 'The AI service returned an error.', 'neccospeak' ) ),
					$out_code
				);
			}
			// design §5.6 L1: a follow-up round failed — fall back to the last
			// successful round's content rather than losing the whole turn.
			$degraded = 'upstream';
			break;
		}

		$raw_body  = wp_remote_retrieve_body( $response );
		$data      = json_decode( $raw_body, true );
		$round_use = ( is_array( $data ) && isset( $data['usage'] ) && is_array( $data['usage'] ) ) ? $data['usage'] : array();

		// ---- Phase 5 G3(a) (design §6.5(C)), Phase 6 §5.5: usage is recorded
		//      PER ROUND, immediately after ITS 2xx response — never deferred
		//      to "after the loop" (a php-fpm request_terminate_timeout kill
		//      skips shutdown entirely, so anything deferred would be lost
		//      while OpenAI already billed the call). With tools disabled this
		//      runs exactly once, with arguments byte-for-byte identical to
		//      dev.5's inline call (INV-1). ----
		neccospeak_chat_usage_record_round( $model, $round_use, $opts );
		$last_good_data = $data;

		$tool_calls = neccospeak_chat_extract_tool_calls( $data );
		if ( empty( $tool_calls ) ) {
			break; // Normal (non-tool) response — done.
		}

		if ( $round >= $max_rounds ) {
			$degraded = 'rounds'; // design §5.6 L3.
			break;
		}
		if ( microtime( true ) >= $deadline - NECCOSPEAK_CHAT_UPSTREAM_MIN_TIMEOUT ) {
			$degraded = 'budget'; // design §5.6 L4.
			break;
		}

		$tool_calls = array_slice( $tool_calls, 0, NECCOSPEAK_TOOL_MAX_CALLS_PER_ROUND );
		$results    = array();
		$hard_fail  = false;

		foreach ( $tool_calls as $tc ) {
			$left = NECCOSPEAK_TOOL_EXEC_BUDGET_MS - $exec_ms;
			if ( $left <= 0 ) {
				$results[] = array(
					'ok'        => false,
					'content'   => '{"error":"budget_exhausted"}',
					'error'     => 'budget_exhausted',
					'ms'        => 0,
					'truncated' => false,
				);
				$hard_fail = true;
				continue;
			}
			$r = function_exists( 'neccospeak_tools_execute' )
				? neccospeak_tools_execute( $tc['name'], $tc['arguments'], $opts, min( $left, NECCOSPEAK_TOOL_EXEC_CALL_MS ) )
				: array(
					'ok'        => false,
					'content'   => '{"error":"tool_unavailable"}',
					'error'     => 'unavailable',
					'ms'        => 0,
					'truncated' => false,
				);
			$exec_ms  += isset( $r['ms'] ) ? (int) $r['ms'] : 0;
			$results[] = $r;
			if ( empty( $r['ok'] ) ) {
				$hard_fail = true;
			}
		}

		// design §5.3 SM-2: always append matching tool messages, even on
		// failure — OpenAI 400s the NEXT round otherwise.
		$messages = array_merge( $messages, neccospeak_chat_tool_messages( $tool_calls, $results ) );
		++$round;

		if ( $hard_fail && $round >= $max_rounds ) {
			$degraded = 'exec'; // design §5.6 L2, recorded only — the loop already exits via the round check above next iteration.
		}
	}

	// ---- Phase 6 G16 (design §5.6): degrade toward a 200 response wherever
	//      possible; 'upstream' reuses the last successful round's data. ----
	if ( 'upstream' === $degraded && null !== $last_good_data ) {
		$data = $last_good_data;
	}

	$reply = '';
	if ( is_array( $data ) && isset( $data['choices'][0]['message']['content'] ) && is_string( $data['choices'][0]['message']['content'] ) ) {
		$reply = $data['choices'][0]['message']['content'];
	}

	if ( '' === trim( $reply ) ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Empty response from the AI service.', 'neccospeak' ) ),
			502
		);
	}

	// ---- G21 (b): mask banned words in the reply (design rev.2 §7.5). ----
	if ( ! empty( $banned ) && ! empty( $opts['banned_mask_output'] ) ) {
		$reply = neccospeak_banned_mask_text( $reply, $banned );
	}

	// ---- Phase 6 G15 (design §2.5 順9): downgrade any of this site's own
	//      URLs that don't verify to plain text, AFTER the G21 output mask
	//      (masking can corrupt a URL, and a corrupted URL should fail
	//      verification and get downgraded — not the other way around).
	//      function_exists()-guarded: includes/page-context.php may not be
	//      loaded. ----
	if ( function_exists( 'neccospeak_page_context_linkify_reply' ) ) {
		$reply = neccospeak_page_context_linkify_reply( $reply );
	}

	// 2026-08 撤去記録: ここにあった neccospeak_maybe_log_conversation() 呼び出し
	// (最終 $reply 確定後の会話ログ記録)は、会話ログ機能ごと削除した。

	return new WP_REST_Response( array( 'reply' => $reply ), 200 );
}

/**
 * 0.3.0-dev.37 (会話フロー本番パリティ フェーズ1 §5) — the `tools` array sent with
 * the Realtime session, i.e. the ONLY place a voice-mode tool definition
 * reaches the model.
 *
 * Background (why this function did not exist before): the ported client
 * carries a complete `navigate_page` handler
 * (assets/widget-frame/js/openai-realtime-agent/tool/tool-handler.js, wired to
 * `handleAction()` in widget-app-persistent.js) and NS's realtime connector
 * hard-codes `tools: []` on both the token request and every `session.update`
 * — deliberately, because in production the tool catalogue comes from the SaaS
 * backend, which NS does not have. So the tool was unreachable end to end: no
 * definition ever reached OpenAI, and even if the model had guessed the call,
 * `navigation.url_map` was a hard-coded empty array. §5 fixes both halves.
 * The server is the right owner: it already composes `instructions` for this
 * same session, so the tool definition and the "## Site pages" section the
 * model reads it against are built from one source of truth
 * (`neccospeak_page_context_nav_url_map()`).
 *
 * Schema note: the Realtime API takes FLAT function definitions
 * (`{type:'function',name,description,parameters}`), NOT the Chat Completions
 * nesting under a `function` key that includes/tools.php's
 * `neccospeak_tools_specs()` emits. Do not unify the two.
 *
 * `category`'s enum is the url_map's own key list (page titles), so the model
 * physically cannot emit a value the client would reject with "Invalid
 * Category", and the description points at the section of the instructions
 * where those exact titles are listed. `nav_list_max` (1-50) is what bounds
 * the enum size.
 *
 * Returns an empty array whenever `nav_list_enabled` is off or no published
 * page resolved (INV-1: an all-default install sends no `tools` key at all,
 * exactly as before dev.37).
 *
 * @param array $opts neccospeak_get_options().
 * @return array<int,array<string,mixed>>
 */
function neccospeak_realtime_tools( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();

	$url_map = function_exists( 'neccospeak_page_context_nav_url_map' )
		? neccospeak_page_context_nav_url_map( $opts )
		: array();
	if ( empty( $url_map ) ) {
		return array();
	}

	$categories = array_values( array_map( 'strval', array_keys( $url_map ) ) );

	return array(
		array(
			'type'        => 'function',
			'name'        => 'navigate_page',
			'description' => __( 'Open one of this site\'s pages in the visitor\'s browser. Use it only when the visitor explicitly asks to open or be taken to a page. Never navigate on your own judgement, even when a page seems relevant — offer the page first and wait for the visitor to agree. The "category" argument must be one of the page titles listed under "Site pages" in your instructions, copied exactly. After calling this, say only that you are showing the page — never read out a URL.', 'neccospeak' ),
			'parameters'  => array(
				'type'                 => 'object',
				'properties'           => array(
					'category' => array(
						'type'        => 'string',
						'description' => __( 'The exact title of the page to open, as listed under "Site pages" in your instructions.', 'neccospeak' ),
						'enum'        => $categories,
					),
				),
				'required'             => array( 'category' ),
				'additionalProperties' => false,
			),
		),
	);
}

/**
 * Build the OpenAI Realtime session object (G40, design rev.2 §9.3).
 *
 * Pure function so it can be exercised from tests/test-rest.php without any
 * HTTP call, matching the existing neccospeak_build_chat_payload() design.
 *
 * GA schema note (design rev.2 §9.2): 'turn_detection' lives under
 * session.audio.input (not at the session root, unlike the beta-era shape),
 * and 'eagerness' is only valid together with turn_detection.type ===
 * 'semantic_vad'. On 'auto' both settings are omitted entirely so OpenAI
 * applies its own defaults (auto language detection / server_vad).
 *
 * @param array  $opts         neccospeak_get_options().
 * @param string $model        Resolved realtime model id.
 * @param string $voice        Resolved voice name.
 * @param string $instructions Composed system prompt ('' to omit).
 * @return array
 */
function neccospeak_build_realtime_session( $opts, $model, $voice, $instructions ) {
	$opts = is_array( $opts ) ? $opts : array();

	$session = array(
		'type'              => 'realtime',
		'model'             => $model,
		'output_modalities' => array( 'audio' ),
		'audio'             => array(
			// Transcribe the visitor's speech so the widget can render the voice
			// conversation as text too (events arrive over the WebRTC data channel).
			'input'  => array(
				'transcription' => array(
					'model' => 'whisper-1',
				),
			),
			'output' => array(
				'voice' => $voice,
			),
		),
	);

	// G40-a: input transcription language. 'auto' means "send nothing" so
	// OpenAI keeps auto-detecting; an explicit code improves accuracy and latency.
	$allowed_langs = array( 'ja', 'en', 'zh', 'ko', 'es', 'fr', 'de', 'pt' );
	$lang          = isset( $opts['transcription_language'] ) ? (string) $opts['transcription_language'] : 'auto';
	if ( in_array( $lang, $allowed_langs, true ) ) {
		$session['audio']['input']['transcription']['language'] = $lang;
	}

	// G40-b: turn-taking eagerness. 'eagerness' is only accepted together with
	// turn_detection.type = 'semantic_vad'; on 'auto' we omit turn_detection
	// entirely and OpenAI applies its own default (server_vad).
	$allowed_eagerness = array( 'low', 'medium', 'high' );
	$eagerness         = isset( $opts['turn_eagerness'] ) ? (string) $opts['turn_eagerness'] : 'auto';
	if ( in_array( $eagerness, $allowed_eagerness, true ) ) {
		$session['audio']['input']['turn_detection'] = array(
			'type'      => 'semantic_vad',
			'eagerness' => $eagerness,
		);
	}

	if ( '' !== trim( (string) $instructions ) ) {
		$session['instructions'] = $instructions;
	}

	// 0.3.0-dev.37 (§5): voice-mode tool definitions. Absent (not empty) when
	// there is nothing to offer, so the default session payload is unchanged
	// byte for byte — see neccospeak_realtime_tools()'s docblock.
	$tools = neccospeak_realtime_tools( $opts );
	if ( ! empty( $tools ) ) {
		$session['tools'] = $tools;
	}

	return $session;
}

/**
 * POST /realtime-token handler. Creates an OpenAI Realtime session and returns
 * ONLY the short-lived client secret (never the real API key).
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function neccospeak_rest_realtime_token( $req ) {
	$pre = neccospeak_rest_preflight( $req, 'realtime' );
	if ( $pre instanceof WP_REST_Response || is_wp_error( $pre ) ) {
		return $pre;
	}
	$api_key = $pre;

	$voice_enabled = (bool) neccospeak_option( 'voice_enabled', false );
	if ( ! $voice_enabled ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Voice is disabled.', 'neccospeak' ) ),
			403
		);
	}

	// Realtime model — trust the admin-chosen value and pass it through (same
	// pass-through policy as the chat model). Only fall back to the default when
	// the option is empty, so future model names (gpt-5-realtime, o-series, …)
	// follow from the admin field alone with no code change. A wrong model name
	// simply makes OpenAI return an error, which the existing handling neutralizes.
	$model = (string) neccospeak_option( 'realtime_model', 'gpt-realtime-2' );
	if ( '' === trim( $model ) ) {
		$model = 'gpt-realtime-2';
	}

	$voice = (string) neccospeak_option( 'voice', 'alloy' );
	if ( '' === trim( $voice ) ) {
		$voice = 'alloy';
	}

	// Instructions are sent AS-IS (Recon); the shared composer applies the
	// byte budget with a UTF-8-safe cut (design rev.2 §0.2.2) instead of the
	// previous raw substr().
	$instructions = neccospeak_compose_system_prompt( neccospeak_get_options(), 'realtime' );

	$session = neccospeak_build_realtime_session( neccospeak_get_options(), $model, $voice, $instructions );

	$payload = array(
		'expires_after' => array(
			'anchor'  => 'created_at',
			'seconds' => 600, // within Recon hard cap [1-1800].
		),
		'session'       => $session,
	);

	$response = wp_remote_post(
		NECCOSPEAK_OPENAI_REALTIME_SECRETS_URL,
		array(
			'timeout' => 30,
			'headers' => array(
				'Authorization' => 'Bearer ' . $api_key,
				'Content-Type'  => 'application/json',
			),
			'body'    => wp_json_encode( $payload ),
		)
	);

	if ( is_wp_error( $response ) ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak realtime transport error: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		return new WP_REST_Response(
			array( 'error' => __( 'Upstream request failed.', 'neccospeak' ) ),
			502
		);
	}

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

	if ( $code < 200 || $code >= 300 ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// Realtime response bodies can carry a client secret; log status only,
			// never the body.
			error_log( 'NeccoSpeak realtime upstream HTTP ' . $code ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		$out_code = ( 429 === $code ) ? 429 : 502;
		return new WP_REST_Response(
			array( 'error' => __( 'The AI service returned an error.', 'neccospeak' ) ),
			$out_code
		);
	}

	// Extract ONLY the ephemeral token + expiry. Never forward the full body/key.
	$token   = '';
	$expires = 0;
	if ( is_array( $data ) ) {
		if ( isset( $data['client_secret']['value'] ) && is_string( $data['client_secret']['value'] ) ) {
			$token = $data['client_secret']['value'];
		} elseif ( isset( $data['value'] ) && is_string( $data['value'] ) ) {
			$token = $data['value'];
		}
		if ( isset( $data['client_secret']['expires_at'] ) ) {
			$expires = (int) $data['client_secret']['expires_at'];
		} elseif ( isset( $data['expires_at'] ) ) {
			$expires = (int) $data['expires_at'];
		}
	}

	if ( '' === $token ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak realtime: no client_secret in response.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		return new WP_REST_Response(
			array( 'error' => __( 'Could not create a realtime session.', 'neccospeak' ) ),
			502
		);
	}

	return new WP_REST_Response(
		array(
			'client_secret' => array(
				'value'      => $token,
				'expires_at' => $expires,
			),
			'model'         => $model,
			'voice'         => $voice,
		),
		200
	);
}

/**
 * GET /models handler. Returns the live OpenAI model catalog for the settings
 * page so the admin never has to maintain a hardcoded model list.
 *
 * Admin-only: the route's permission_callback already enforces
 * current_user_can('manage_options'); on top of that we require a valid
 * X-WP-Nonce ('wp_rest'). The OpenAI API key is decrypted and used ONLY on the
 * server side — it is never sent to the browser.
 *
 * NOTE: GET /v1/models is a free metadata call — it does NOT consume tokens and
 * is not billed. We send only the Authorization header (no body).
 *
 * Response shape: { "chat": [ids…], "realtime": [ids…], "all": [ids…] }.
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function neccospeak_rest_models( $req ) {
	// Nonce is mandatory here (admin context); manage_options is enforced by the
	// route's permission_callback. Reject any present-but-invalid or missing nonce.
	$nonce = $req->get_header( 'X-WP-Nonce' );
	if ( empty( $nonce ) || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
		return new WP_REST_Response(
			array( 'error' => __( 'Forbidden.', 'neccospeak' ) ),
			403
		);
	}

	$enc = (string) neccospeak_option( 'openai_api_key', '' );
	$key = '' !== $enc ? (string) neccospeak_decrypt( $enc ) : '';
	if ( '' === $key ) {
		return new WP_REST_Response(
			array( 'error' => __( 'No API key is configured. Please save your API key first.', 'neccospeak' ) ),
			400
		);
	}

	// Cache the catalog for 6 hours. The cache key is varied by a hash of the
	// API key so that changing the key invalidates the cache (a different key
	// may have access to a different set of models).
	$cache_key = 'neccospeak_models_cache_' . substr( md5( $key ), 0, 16 );
	$cached    = get_transient( $cache_key );
	if ( is_array( $cached ) && isset( $cached['all'] ) ) {
		return new WP_REST_Response( $cached, 200 );
	}

	$response = wp_remote_get(
		NECCOSPEAK_OPENAI_MODELS_URL,
		array(
			'timeout' => 30,
			'headers' => array(
				'Authorization' => 'Bearer ' . $key,
			),
		)
	);

	if ( is_wp_error( $response ) ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak models transport error: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		return new WP_REST_Response(
			array( 'error' => __( 'Could not reach the AI service to list models.', 'neccospeak' ) ),
			502
		);
	}

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

	if ( $code < 200 || $code >= 300 ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( 'NeccoSpeak models upstream HTTP ' . $code . ': ' . neccospeak_log_trunc( $raw ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-guarded diagnostic logging; never user-facing.
		}
		// Surface a concise admin-facing message; never echo provider body/key.
		if ( 401 === $code || 403 === $code ) {
			return new WP_REST_Response(
				array( 'error' => __( 'The AI service rejected the API key. Please check your key.', 'neccospeak' ) ),
				400
			);
		}
		return new WP_REST_Response(
			array( 'error' => __( 'The AI service returned an error while listing models.', 'neccospeak' ) ),
			502
		);
	}

	// Extract data[].id.
	$ids = array();
	if ( is_array( $data ) && isset( $data['data'] ) && is_array( $data['data'] ) ) {
		foreach ( $data['data'] as $item ) {
			if ( is_array( $item ) && isset( $item['id'] ) && is_string( $item['id'] ) && '' !== $item['id'] ) {
				$ids[] = $item['id'];
			}
		}
	}
	$ids = array_values( array_unique( $ids ) );

	// Loose filtering.
	$realtime = array();
	$chat     = array();
	$exclude  = array( 'realtime', 'audio', 'tts', 'whisper', 'embedding', 'image', 'dall' );
	foreach ( $ids as $id ) {
		if ( false !== strpos( $id, 'realtime' ) ) {
			$realtime[] = $id;
			continue;
		}
		if ( 0 === strpos( $id, 'gpt' ) ) {
			$is_chat = true;
			foreach ( $exclude as $needle ) {
				if ( false !== strpos( $id, $needle ) ) {
					$is_chat = false;
					break;
				}
			}
			if ( $is_chat ) {
				$chat[] = $id;
			}
		}
	}

	sort( $ids );
	sort( $chat );
	sort( $realtime );

	$out = array(
		'chat'     => array_values( $chat ),
		'realtime' => array_values( $realtime ),
		'all'      => array_values( $ids ),
	);

	set_transient( $cache_key, $out, NECCOSPEAK_MODELS_CACHE_TTL );

	return new WP_REST_Response( $out, 200 );
}

/**
 * GET|POST /ping handler. Public reachability probe (design rev.2 §10.4)
 * used by the Diagnostics tab (C2 anonymous GET / C2b anonymous POST) and by
 * manual QA (M14) to confirm the REST namespace itself is reachable to
 * anonymous visitors — independent of /chat's rate limits or whether an
 * OpenAI key is configured. Never touches OpenAI, the database, or rate
 * limits. Always treated as anonymous (§0.2.3-b), matching the other public
 * routes.
 *
 * @param WP_REST_Request $req Request.
 * @return WP_REST_Response
 */
function neccospeak_rest_ping( $req ) {
	wp_set_current_user( 0 );

	if ( 'POST' === strtoupper( $req->get_method() ) ) {
		$guard = neccospeak_rest_guard( $req );
		if ( $guard instanceof WP_REST_Response ) {
			return $guard;
		}
		return new WP_REST_Response(
			array(
				'ok'     => true,
				'method' => 'POST',
				'guard'  => 'passed',
			),
			200
		);
	}

	return new WP_REST_Response(
		array(
			'ok'     => true,
			'method' => 'GET',
		),
		200
	);
}
