<?php
/**
 * NeccoSpeak — Widget frame delivery (docs/transplant-plan.md §2 / §4 / §5.1-5.2,
 * 0.3.0-dev.1 "static copy transplant" of the production widget).
 *
 * This file serves the iframe DOCUMENT the front-end loader
 * (assets/widget-frame/js/widget-loader.js) mounts, at:
 *
 *     GET {home_url}/?neccospeak_frame=1&v={NECCOSPEAK_VERSION}
 *
 * — a `template_redirect` priority-0 query-var endpoint (no rewrite rule,
 * design doc §2.1: no flush_rewrite_rules() dependency, and it runs before
 * `redirect_canonical` at priority 10 so it is never swept into a canonical
 * redirect). D2 (design doc §0.1): the widget is rendered same-origin inside
 * an iframe so the visitor's browser replays whatever BASIC-auth credentials
 * the parent page already established (§2.2) — a cross-origin iframe would
 * not get that for free.
 *
 * Ownership (design doc §6.1, 班A / "band A"): this file is band A's alone.
 * `neccospeak_enqueue()` in neccospeak.php (also band A) wires
 * `window.NECCOSPEAK_LAUNCHER` = neccospeak_frame_launcher_config( $opts )
 * onto the front-end loader script — that call lives in neccospeak.php, not
 * here, since it is the PARENT page's config, not the framed document's own.
 *
 * INV (mirrors includes/rest.php's own INV-10 discipline, design doc §2.1's
 * own note): the ONLY function in this file allowed to call exit()/die() is
 * neccospeak_frame_maybe_render() — both its early-404 branch and its
 * success branch. No other function in this file may terminate the request.
 *
 * i18n note (deliberate exception to this plugin's usual English-source-
 * string convention seen elsewhere in includes/*.php): every user-visible
 * string introduced by this file uses its production Japanese text AS the
 * msgid (matching the copied EJS/JS source 1:1), not an English source
 * string translated into Japanese via languages/neccospeak-ja.po. This is
 * required by D1 ("完全同一" — pixel/text-identical to production) without
 * making Phase A's test-hp acceptance (design doc §8) depend on a separate
 * ja.po/mo translation pass landing first — gettext returns the msgid
 * verbatim wherever no translation exists, which for a Japanese msgid IS
 * already the correct production text. See docs/i18n-new-strings-frame.txt.
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/*
-------------------------------------------------------------------------
 * 2.1 — Delivery route.
 * ----------------------------------------------------------------------
 */

/**
 * Registers the `neccospeak_frame` public query var (design doc §2.1).
 *
 * @param string[] $vars Existing public query vars.
 * @return string[]
 */
function neccospeak_frame_query_vars( $vars ) {
	$vars[] = 'neccospeak_frame';
	return $vars;
}
add_filter( 'query_vars', 'neccospeak_frame_query_vars' );

/**
 * Renders the widget frame document and terminates the request when
 * `?neccospeak_frame=1` is present (design doc §2.1's handler pseudocode).
 * A total no-op (plain `return`, request falls through to normal WP
 * rendering) for every other request — including admin/AJAX/REST requests
 * that happen to carry the query var for some unrelated reason.
 *
 * @return void
 */
function neccospeak_frame_maybe_render() {
	if ( '1' !== (string) get_query_var( 'neccospeak_frame', '' ) ) {
		return;
	}
	// Never inside wp-admin, an AJAX request, or a REST request — the frame
	// is a front-end-only document (design doc §2.1 step 2).
	if ( is_admin() || ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
		return;
	}

	$opts = function_exists( 'neccospeak_get_options' ) ? neccospeak_get_options() : array();
	$opts = is_array( $opts ) ? $opts : array();

	// Gate (design doc §2.1 step 3): deliberately NOT neccospeak_should_display()
	// — that also evaluates the URL display_rule, which the frame URL itself
	// is not subject to (the parent-page loader already decided whether to
	// show a launcher at all; the frame document only needs the hard
	// preconditions: enabled + a working API key).
	$enabled = ! empty( $opts['enabled'] );
	$key_ok  = false;
	if ( $enabled ) {
		$encrypted = ( isset( $opts['openai_api_key'] ) && is_string( $opts['openai_api_key'] ) ) ? $opts['openai_api_key'] : '';
		if ( '' !== trim( $encrypted ) && function_exists( 'neccospeak_decrypt' ) ) {
			$key_ok = ( '' !== trim( (string) neccospeak_decrypt( $encrypted ) ) );
		}
	}

	if ( ! $enabled || ! $key_ok ) {
		if ( function_exists( 'status_header' ) ) {
			status_header( 404 );
		}
		if ( function_exists( 'nocache_headers' ) ) {
			nocache_headers();
		}
		exit;
	}

	// phpcs:ignore WordPress.PHP.NoSilencedErrors -- random_bytes() itself
	// never silently degrades; no @ suppression is used here or below.
	$nonce = base64_encode( random_bytes( 16 ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- this is a CSP/script nonce, not obfuscated output; it is never decoded back into meaningful data.

	neccospeak_frame_headers( $nonce );
	echo neccospeak_frame_html( $opts, $nonce ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- neccospeak_frame_html() escapes every dynamic value at its own point of output (R-I18N-1/R-THEME-2, design doc §4.1); this line is markup assembly, not raw unescaped input.
	exit;
}
add_action( 'template_redirect', 'neccospeak_frame_maybe_render', 0 );

/*
-------------------------------------------------------------------------
 * 2.3 — Response headers.
 * ----------------------------------------------------------------------
 */

/**
 * Builds the Content-Security-Policy header value (design doc §2.3). Split
 * out from neccospeak_frame_headers() purely so it is directly unit
 * testable (P7: the nonce inside this string must match the nonce inside
 * the 3 `<script nonce="…">` tags neccospeak_frame_html() emits).
 *
 * @param string $nonce The per-request CSP/script nonce.
 * @return string
 */
function neccospeak_frame_csp( $nonce ) {
	$nonce      = (string) $nonce;
	$directives = array(
		"default-src 'none'",
		"script-src 'self' 'nonce-" . $nonce . "'",
		"style-src 'self' 'unsafe-inline'",
		"font-src 'self'",
		'img-src \'self\' data: https:',
		'media-src blob: data:',
		"connect-src 'self' https://api.openai.com",
		"frame-ancestors 'self'",
		"base-uri 'self'",
		"form-action 'none'",
		"object-src 'none'",
	);
	return implode( '; ', $directives );
}

/**
 * 0.3.0-dev.64 — the ES-module import map that version-busts the WHOLE
 * frame module graph.
 *
 * Why this exists: only the entry import
 * (widget-app-persistent.js?ver=NECCOSPEAK_VERSION) ever carried a version
 * query. Every deeper `import "./x.js"` resolved to a bare URL with no
 * cache buster, so after a deploy a browser could run a MIXED graph — a
 * fresh entry module importing stale cached dependencies (heuristic HTTP
 * caching; Apache ships no Cache-Control for static .js). Observed in the
 * field on dev.63: the new widget-app-persistent.js passed exactDevice to a
 * STALE openai-realtime-connector.js whose old getMediaStream(deviceId)
 * silently ignored the second argument, so the mic switch kept using the
 * ideal constraint and "switched" back to the old device.
 *
 * The map lists every .js file under assets/widget-frame/js/ and maps its
 * bare URL to the same URL + ?ver=. Relative specifiers are resolved to
 * absolute URLs BEFORE map lookup, so `import "../widget/x.js"` from any
 * module (including remapped ?ver ones — the query is dropped during
 * resolution of the importer-relative specifier, not the importer URL)
 * lands on a bare-URL key and comes back version-busted. Browsers without
 * import-map support ignore the map and keep today's behavior (works, but
 * heuristically cacheable) — pure progressive enhancement. The frame HTML
 * itself is no-store (nocache_headers() below), so a new plugin version
 * reaches every visitor's NEXT frame load with no manual cache clearing.
 *
 * @param string $base    NECCOSPEAK_URL (trailing slash).
 * @param string $version NECCOSPEAK_VERSION ('' disables the map).
 * @return array<string,string> imports map (URL => URL?ver=...), empty on failure.
 */
function neccospeak_frame_module_import_map( $base, $version ) {
	$base    = (string) $base;
	$version = (string) $version;
	if ( '' === $base || '' === $version || ! defined( 'NECCOSPEAK_DIR' ) ) {
		return array();
	}

	$js_dir = NECCOSPEAK_DIR . 'assets/widget-frame/js';
	if ( ! is_dir( $js_dir ) ) {
		return array();
	}

	$files = array();
	try {
		$it = new RecursiveIteratorIterator(
			new RecursiveDirectoryIterator( $js_dir, FilesystemIterator::SKIP_DOTS )
		);
		foreach ( $it as $file ) {
			if ( $file->isFile() && 'js' === strtolower( $file->getExtension() ) ) {
				// Relative path under js/, with OS-independent forward slashes.
				$rel     = substr( $file->getPathname(), strlen( $js_dir ) + 1 );
				$files[] = str_replace( '\\', '/', $rel );
			}
		}
	} catch ( Exception $e ) {
		return array(); // Unreadable dir — degrade to today's (map-less) behavior.
	}
	sort( $files, SORT_STRING ); // Deterministic output (testable, diffable view-source).

	$ver_qs = '?ver=' . rawurlencode( $version );
	$map    = array();
	foreach ( $files as $rel ) {
		$url         = $base . 'assets/widget-frame/js/' . $rel;
		$map[ $url ] = $url . $ver_qs;
	}
	return $map;
}

/**
 * Sends every response header the framed document requires (design doc
 * §2.3). `Cache-Control: no-store` (via nocache_headers()) is required
 * because the document inlines the current settings snapshot at request
 * time (design doc §2.4).
 *
 * @param string $nonce The per-request CSP/script nonce (matches the 3
 *                       `<script nonce>` tags neccospeak_frame_html() emits).
 * @return void
 */
function neccospeak_frame_headers( $nonce ) {
	header( 'Content-Type: text/html; charset=UTF-8' );
	if ( function_exists( 'nocache_headers' ) ) {
		nocache_headers(); // Sends Cache-Control/Expires/Pragma equivalent to "no-store, no-cache, must-revalidate, max-age=0".
	} else {
		header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
	}
	header( 'X-Robots-Tag: noindex, nofollow' );
	header( 'Referrer-Policy: same-origin' );
	// geolocation=() — the getCurrentPosition tool is deleted (design doc
	// §5.4), so the iframe never needs it; matches the `allow` attribute the
	// parent-page loader sets on the <iframe> (design doc §2.3 note).
	header( 'Permissions-Policy: microphone=(self), clipboard-write=(self), geolocation=()' );
	// `true` = replace, so a host theme/plugin's own broader CSP header never
	// silently widens this one (design doc §2.3).
	header( 'Content-Security-Policy: ' . neccospeak_frame_csp( $nonce ), true );
}

/*
-------------------------------------------------------------------------
 * Color helpers (R-THEME-2: values are validated, never string-interpolated
 * raw).
 * ----------------------------------------------------------------------
 */

/**
 * R-THEME-2 color validator: `/^#[0-9A-Fa-f]{3,8}$/` (design doc §4.1).
 *
 * @param mixed $v Candidate value.
 * @return bool
 */
function neccospeak_frame_is_hex_color( $v ) {
	return is_string( $v ) && '' !== $v && 1 === preg_match( '/^#[0-9A-Fa-f]{3,8}$/', $v );
}

/**
 * `#rrggbb` (or `#rgb`) → `"r, g, b"` decimal triplet, for the
 * `--agent-primary-rgb` rgba() fallback (design doc §4.2, EJS/CSS parity —
 * iOS Safari < 16.4 has no color-mix()). '' for anything that is not
 * cleanly a 3- or 6-digit hex color (an 4/8-digit alpha form is a valid
 * --agent-primary value under R-THEME-2 but has no single unambiguous RGB
 * triplet, so it is simply skipped here rather than guessed at).
 *
 * @param string $hex A hex color, e.g. '#2f6fed'.
 * @return string
 */
function neccospeak_frame_hex_to_rgb( $hex ) {
	$hex = is_string( $hex ) ? trim( $hex ) : '';
	if ( 1 !== preg_match( '/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $hex ) ) {
		return '';
	}
	$c = substr( $hex, 1 );
	if ( 3 === strlen( $c ) ) {
		$c = $c[0] . $c[0] . $c[1] . $c[1] . $c[2] . $c[2];
	}
	return hexdec( substr( $c, 0, 2 ) ) . ', ' . hexdec( substr( $c, 2, 2 ) ) . ', ' . hexdec( substr( $c, 4, 2 ) );
}

/**
 * Darkens a hex color by flatly subtracting `$amount` from each RGB channel
 * (clamped at 0) — deliberately the SAME algorithm as
 * assets/widget-frame/js/widget-loader.js's own `darkenHex()` (design doc
 * §4.2: "widget-loaderのdarkenHexと同アルゴリズム"), NOT a percentage
 * multiply, so `primaryColorDark` matches pixel-for-pixel regardless of
 * whether the PHP or the JS side happens to compute it for a given call
 * site. '' for anything neccospeak_frame_is_hex_color()-shaped as a 4/8-digit
 * alpha color, or not a valid hex color at all (same "skip rather than
 * guess" stance as neccospeak_frame_hex_to_rgb() above).
 *
 * @param string $hex    A hex color, e.g. '#2f6fed'.
 * @param int    $amount Flat per-channel amount to subtract (0-255).
 * @return string Darkened `#rrggbb`, or ''.
 */
function neccospeak_frame_darken( $hex, $amount ) {
	$hex = is_string( $hex ) ? trim( $hex ) : '';
	if ( 1 !== preg_match( '/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/', $hex ) ) {
		return '';
	}
	$c = substr( $hex, 1 );
	if ( 3 === strlen( $c ) ) {
		$c = $c[0] . $c[0] . $c[1] . $c[1] . $c[2] . $c[2];
	}
	$amount = (int) $amount;
	$r      = max( 0, hexdec( substr( $c, 0, 2 ) ) - $amount );
	$g      = max( 0, hexdec( substr( $c, 2, 2 ) ) - $amount );
	$b      = max( 0, hexdec( substr( $c, 4, 2 ) ) - $amount );
	return sprintf( '#%02x%02x%02x', $r, $g, $b );
}

/*
-------------------------------------------------------------------------
 * Theme CSS variable generation (R-THEME-1 / R-THEME-2 / INV-T8).
 * ----------------------------------------------------------------------
 */

/**
 * `--agent-primary` / `--agent-primary-rgb` / `--agent-primary-dark` (design
 * doc §4.2). Deliberately NOT governed by R-THEME-1's zero-emission rule and
 * kept OUT of neccospeak_frame_theme_css() below: `brand_color` always has a
 * non-empty value (its sanitize type is 'color', not 'color_optional' —
 * includes/settings.php's neccospeak_default_options()), so EJS's own
 * `if (primaryColor)` guard was always true in practice; and unlike the
 * neccospeak_frame_theme_css() variable group, none of these three has a
 * `@media (prefers-color-scheme: dark)` override in widget-frame.css to
 * accidentally suppress (that CSS file's own comment: "ブランドカラー…は
 * 明色/暗色どちらでも見えるよう敢えて変更しない") — so unconditional emission
 * cannot break INV-T8/dark mode the way the R-THEME-1 group could.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string `--agent-primary*` CSS declarations (no wrapping `:root{}`).
 */
function neccospeak_frame_primary_css( $opts ) {
	$opts    = is_array( $opts ) ? $opts : array();
	$primary = ( array_key_exists( 'brand_color', $opts ) && is_scalar( $opts['brand_color'] ) ) ? (string) $opts['brand_color'] : '';
	if ( '' === trim( $primary ) ) {
		$primary = '#2f6fed';
	}
	if ( ! neccospeak_frame_is_hex_color( $primary ) ) {
		return '';
	}

	$css = '--agent-primary:' . $primary . ';';

	$rgb = neccospeak_frame_hex_to_rgb( $primary );
	if ( '' !== $rgb ) {
		$css .= '--agent-primary-rgb:' . $rgb . ';';
	}

	$dark = neccospeak_frame_darken( $primary, 13 );
	if ( '' !== $dark ) {
		$css .= '--agent-primary-dark:' . $dark . ';';
	}

	return $css;
}

/**
 * Builds the R-THEME-1/R-THEME-2/INV-T8-governed CSS custom-property
 * declarations for the widget frame (design doc §4.2, mirrors
 * includes/frontend-config.php's neccospeak_widget_theme() pattern but with
 * the ORIGINAL, unrenamed `--agent-*` variable names widget-frame.css itself
 * declares — this is a deliberately separate, parallel implementation, not a
 * call into that function, precisely because the variable names differ and
 * frontend-config.php is a shared file band A does not own).
 *
 * Zero-emission (INV-T8): every variable here is emitted ONLY when the
 * backing option is explicitly set to a non-default value — EXCEPT
 * `--agent-radius`, which is always emitted (see its own comment below),
 * and the dark-block pin group (2026-08 product decision, see the
 * `$light_pin` comment inside): those variables are always emitted so the
 * widget never renders the widget-frame.css dark palette.
 * `--agent-primary*` are handled separately by neccospeak_frame_primary_css()
 * above, not here.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string CSS custom-property declarations (no wrapping `:root{}`).
 */
function neccospeak_frame_theme_css( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$s    = function ( $key, $default = '' ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (string) $opts[ $key ] : (string) $default;
	};
	$i    = function ( $key, $default ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (int) $opts[ $key ] : (int) $default;
	};

	$vars = array();

	// themeColors group (EJS L24-38 minus --agent-system-*, which has no
	// backing NS option — but IS consumed by the system-message bubble rule
	// (widget-frame.css:359-360; the earlier "no matching selector" claim
	// here was wrong), so those two are dark-block-pinned below instead).
	$color_map = array(
		'theme_panel_bg'         => '--agent-bg',
		'theme_thread_bg'        => '--agent-bg-chat',
		'theme_panel_text'       => '--agent-text',
		'theme_panel_muted'      => '--agent-text-muted',
		'theme_panel_border'     => '--agent-border',
		'theme_header_text'      => '--agent-header-text',
		'theme_bubble_user_bg'   => '--agent-user-bg',
		'theme_bubble_user_text' => '--agent-user-text',
		'theme_bubble_bot_bg'    => '--agent-bot-bg',
		'theme_bubble_bot_text'  => '--agent-bot-text',
	);
	// Dark-block pin (2026-08 product decision: the frame never renders
	// dark). widget-frame.css is a verbatim production copy and keeps its
	// `@media (prefers-color-scheme: dark)` block, so every variable that
	// block overrides is pinned here with the css file's own light default
	// whenever the backing option is unset — exactly what production's EJS
	// does (it always emits the tenant themeColors, which is why production
	// never shows the dark palette). Variables the dark block does not touch
	// (--agent-header-text, --agent-user-*) keep the zero-emission rule.
	$light_pin = array(
		'--agent-bg'         => '#FFFFFF',
		'--agent-bg-chat'    => '#F9FAFB',
		'--agent-text'       => '#1F2937',
		'--agent-text-muted' => '#4B5563',
		'--agent-border'     => '#E5E7EB',
		'--agent-bot-bg'     => '#F3F4F6',
		'--agent-bot-text'   => '#1F2937',
	);
	foreach ( $color_map as $opt_key => $var_name ) {
		$v = $s( $opt_key );
		if ( neccospeak_frame_is_hex_color( $v ) ) {
			$vars[ $var_name ] = $v;
		} elseif ( isset( $light_pin[ $var_name ] ) ) {
			$vars[ $var_name ] = $light_pin[ $var_name ];
		}
	}

	// --agent-footer-bg deliberately mirrors theme_panel_bg (same option,
	// same value) rather than being its own option: widget-frame.css's own
	// default does not inherit --agent-bg for this variable, so an admin who
	// only sets a panel background would otherwise see the footer stay white
	// (design doc §4.2's own note on this row). Unset falls back to the
	// css light default (#FFFFFF) — dark-block pin, same rule as above.
	$panel_bg = $s( 'theme_panel_bg' );
	$vars['--agent-footer-bg'] = neccospeak_frame_is_hex_color( $panel_bg ) ? $panel_bg : '#FFFFFF';

	// --agent-system-* (system-message bubbles): no backing NS option, but
	// the dark block overrides both, so they need the same dark-block pin as
	// the $light_pin group — always emitted with the css light defaults
	// (0.3.0-dev.18: OS-dark users saw dark system bubbles without this).
	$vars['--agent-system-bg']   = '#F1F5F9';
	$vars['--agent-system-text'] = '#475569';

	// Header background: solid, or a 2-stop gradient when
	// theme_header_style==='gradient' AND both stops are set (EJS L68-70) —
	// a gradient with only one stop configured degrades to solid.
	$header_style = $s( 'theme_header_style', 'solid' );
	$header_c1    = $s( 'theme_header_bg' );
	$header_c2    = $s( 'theme_header_bg2' );
	if ( 'gradient' === $header_style && neccospeak_frame_is_hex_color( $header_c1 ) && neccospeak_frame_is_hex_color( $header_c2 ) ) {
		$angle                     = max( 0, min( 360, $i( 'theme_header_angle', 135 ) ) );
		$vars['--agent-header-bg'] = sprintf( 'linear-gradient(%ddeg, %s 0%%, %s 100%%)', $angle, $header_c1, $header_c2 );
	} elseif ( neccospeak_frame_is_hex_color( $header_c1 ) ) {
		$vars['--agent-header-bg'] = $header_c1;
	}
	// themeGradients.user_bubble: intentionally never emitted — NS has no
	// matching option (design doc §4.2).

	// Border radius. `--agent-radius` is the ONE documented R-THEME-1
	// exception: widget-frame.css's own literal default is 12px, but
	// production's actual rendered look is 16px (theme_panel_radius's own
	// default) — staying silent here would visibly regress the look on
	// every default install, so this one variable is always emitted.
	$panel_radius           = max( 0, min( 32, $i( 'theme_panel_radius', 16 ) ) );
	$vars['--agent-radius'] = $panel_radius . 'px';
	$bubble_radius          = max( 0, min( 24, $i( 'theme_bubble_radius', 16 ) ) );
	if ( 16 !== $bubble_radius ) {
		$vars['--agent-bubble-radius'] = $bubble_radius . 'px';
	}
	$button_radius = max( 0, min( 24, $i( 'theme_button_radius', 8 ) ) );
	if ( 8 !== $button_radius ) {
		$vars['--agent-button-radius'] = $button_radius . 'px';
	}
	// --agent-card-radius: never emitted — no backing NS option, and
	// widget-frame.css's own 12px literal default is already the production
	// look (design doc §4.2).
	// --agent-input-radius: the second always-emit exception (same rationale
	// as --agent-radius above). Design doc §4.2 assumed the css literal 12px
	// matched production, but the live production frame emits a fixed 24px
	// (2026-08 実測 — the doc's `undefinedpx` quirk note no longer holds).
	// Production has no per-tenant knob for it, so NS pins it option-less too
	// (product decision 2026-08).
	$vars['--agent-input-radius'] = '24px';

	// Font family: fixed PHP-side lookup table (never string-interpolated —
	// R-THEME-2), same table as includes/frontend-config.php's
	// neccospeak_widget_theme() (duplicated rather than called — that
	// function emits differently-named --neccospeak-* variables for a
	// different consumer, and frontend-config.php is a shared file band A
	// does not own). 'system' is the zero-emission default.
	$font_family_map = array(
		'sans'    => '"Helvetica Neue", Arial, "Hiragino Kaku Gothic ProN", "Noto Sans JP", sans-serif',
		'serif'   => 'Georgia, "Times New Roman", "Hiragino Mincho ProN", "Noto Serif JP", serif',
		'rounded' => '"Hiragino Maru Gothic ProN", "Quicksand", "Nunito", "M PLUS Rounded 1c", sans-serif',
		'mono'    => 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono", monospace',
		'inherit' => 'inherit',
	);
	$font_family     = $s( 'theme_font_family', 'system' );
	if ( isset( $font_family_map[ $font_family ] ) ) {
		$vars['--agent-font-family'] = $font_family_map[ $font_family ];
	}

	// Font size: base + 2 derived (design doc §4.2 — plain integer px, unlike
	// includes/frontend-config.php's rem-based --neccospeak-font-size; this
	// mirrors EJS's own plain `<%= themeFont.size_base %>px` literally).
	// 15 is the zero-emission default (widget-frame.css's own literal).
	$font_size = max( 12, min( 20, $i( 'theme_font_size', 15 ) ) );
	if ( 15 !== $font_size ) {
		$vars['--agent-font-size']        = $font_size . 'px';
		$vars['--agent-font-size-small']  = ( $font_size - 2 ) . 'px';
		$vars['--agent-font-size-header'] = ( $font_size + 1 ) . 'px';
	}

	// Background watermark: both an attachment AND a non-zero opacity are
	// required. The URL is additionally rejected if it contains any
	// character that could break out of the `url('...')` CSS function
	// (belt-and-braces on top of neccospeak_widget_attachment_url()'s own
	// scheme allow-list).
	$wm_id = $i( 'theme_watermark_id', 0 );
	$wm_op = max( 0, min( 100, $i( 'theme_watermark_opacity', 6 ) ) );
	if ( $wm_id > 0 && $wm_op > 0 ) {
		$wm_url = function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'theme_watermark_id', 'large' ) : '';
		if ( '' !== $wm_url && ! preg_match( '/["\'()<>\\\\\s]/', $wm_url ) ) {
			$vars['--agent-watermark-url']     = "url('" . $wm_url . "')";
			$vars['--agent-watermark-opacity'] = number_format( $wm_op / 100, 2, '.', '' );
		}
	}

	$css = '';
	foreach ( $vars as $name => $value ) {
		$css .= $name . ':' . $value . ';';
	}
	return $css;
}

/*
-------------------------------------------------------------------------
 * Icon mapping tables.
 * ----------------------------------------------------------------------
 */

/**
 * NS `home_icon` enum → bundled bootstrap-icons suffix for the welcome-view
 * icon `<i class="bi bi-…">` (design doc §4.2's `welcome.icon` row / §1.3's
 * 24-icon subset). Deliberately constrained to names that actually exist in
 * assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css's hand-
 * picked subset — `bi-inbox` and `bi-person-circle` in particular are kept
 * in that subset SPECIFICALLY for this mapping (design doc §1.3's own note:
 * "welcome アイコンの home_icon 設定でユーザーが選べるため残す"), even though
 * their original habitat (the now-deleted #profile-panel) is gone.
 *
 * @param string $home_icon One of 'none'|'chat'|'bubbles'|'sparkle'|'help'|'headset'|'custom'.
 * @return string A bootstrap-icons suffix (without the `bi-` prefix).
 */
function neccospeak_frame_welcome_icon_bi( $home_icon ) {
	$map = array(
		'none'    => 'chat-dots', // EJS's own hardcoded fallback (`welcome.icon || "chat-dots"`).
		'chat'    => 'chat-dots',
		'bubbles' => 'chat-text',
		'sparkle' => 'robot',
		'help'    => 'person-circle',
		'headset' => 'inbox',
	);
	return isset( $map[ $home_icon ] ) ? $map[ $home_icon ] : 'chat-dots';
}

/**
 * NS quick-action-chip / CTA-item icon enum → bundled bootstrap-icons suffix.
 *
 * `neccospeak_widget_home_chips()` (includes/frontend-config.php) and
 * `neccospeak_widget_cta_items()` (same file) both pass through a NS-internal
 * icon name — the 15-entry allow-list at includes/frontend-config.php:353
 * (mirrored by assets/js/widget-cta.js's own `ICON_ALLOWED`) — completely
 * unmapped, since neither of those two functions (both on the "shared file,
 * do not touch" list, §6.1) know anything about Bootstrap Icons. Every
 * consumer of those two functions in *this* file must run each `icon` value
 * through this mapping before it reaches HTML/JSON output, exactly as
 * `neccospeak_frame_welcome_icon_bi()` already does for the unrelated
 * `home_icon` enum. Unknown/empty input maps to '' (i.e. "render no icon"),
 * matching the pre-existing `empty( $chip['icon'] )` guard around the
 * server-rendered chip markup.
 *
 * 0.3.0-dev.37 (会話フロー本番パリティ フェーズ1 §2) — the "never fall back to
 * the raw NS name" rule this docblock used to state absolutely is now scoped
 * to what it was actually protecting against: a mixed-case / unmapped NS enum
 * name ('arrowRight', 'none') really is not a valid `bi-` suffix and must
 * still degrade to ''. But `neccospeak_sanitize_cta_list()` now also stores
 * raw Bootstrap Icons class suffixes verbatim (a second accepted icon
 * vocabulary, validated there against `/^[a-z0-9][a-z0-9-]{0,49}$/` — the
 * exact shape upstream's own class names take). A value that passed that
 * validation is safe to pass through unchanged: re-checking the same pattern
 * here means the output can only ever be a lowercase alnum/hyphen token, so
 * `bi bi-<suffix>` cannot break out of its class attribute even before
 * esc_attr(), and the bundled stylesheet is the full upstream v1.13.1 set
 * (dev.37) so any real upstream name resolves to a glyph. A pattern-matching
 * name that is NOT a real upstream icon renders as nothing at all (no glyph,
 * no tofu) — the same visual outcome as returning ''.
 *
 * @param string $ns_icon One of chat/help/sparkle/doc/calendar/cart/mail/
 *                         phone/link/arrowRight/home/mic/briefcase/building/chart,
 *                         or a raw bootstrap-icons class suffix.
 * @return string A bootstrap-icons suffix (without the `bi-` prefix), or ''.
 */
function neccospeak_frame_chip_icon_bi( $ns_icon ) {
	$map = array(
		'chat'       => 'chat-dots',
		'help'       => 'question-circle',
		'sparkle'    => 'stars',
		'doc'        => 'file-earmark-text',
		'calendar'   => 'calendar-event',
		'cart'       => 'cart',
		'mail'       => 'envelope',
		'phone'      => 'telephone',
		'link'       => 'link-45deg',
		'arrowRight' => 'arrow-right',
		'home'       => 'house-door',
		'mic'        => 'mic-fill',
		// briefcase/chart map to the -fill variants: matched against the
		// production widget's live chip DOM (2026-08 実測 — サービス紹介
		// briefcase-fill / 開発実績 bar-chart-fill; building/telephone are
		// outline in production too, so those rows stay as they are).
		'briefcase'  => 'briefcase-fill',
		'building'   => 'building',
		'chart'      => 'bar-chart-fill',
	);
	if ( isset( $map[ $ns_icon ] ) ) {
		return $map[ $ns_icon ];
	}
	// 'none' is the NS enum's explicit "no icon" member and must never be
	// treated as a bi- suffix, even though it matches the pattern below.
	if ( '' === $ns_icon || 'none' === $ns_icon ) {
		return '';
	}
	// Validated bi-name pass-through (dev.37 — see the docblock).
	if ( preg_match( '/^[a-z0-9][a-z0-9-]{0,49}$/', $ns_icon ) ) {
		return $ns_icon;
	}
	return '';
}

/**
 * 0.3.0-dev.37 (会話フロー本番パリティ フェーズ1 §3/§4) — the `cta_list` option
 * read straight from `$opts`, normalized into what this file's own renderers
 * need: every dev.37 column present (defaults filled in for rows still stored
 * in the pre-dev.37 7-key shape), `enabled === false` rows dropped, and the
 * survivors sorted by `order` ascending.
 *
 * Why not go through `neccospeak_widget_cta_items()` like
 * `neccospeak_frame_cta_items_bi()` does: that function lives in
 * includes/frontend-config.php, a "shared file, do not touch" (§6.1), and
 * rebuilds each row on a FIXED 7-key whitelist — so subtitle/slot/order/
 * enabled are stripped before they could ever reach this file. Reading the
 * option here is also what makes the dev.37 columns work on an install that
 * has not re-saved its CTA settings yet (the sanitizer only canonicalizes on
 * save, so stored rows legitimately still lack the four new keys).
 *
 * `icon` comes back ALREADY mapped through `neccospeak_frame_chip_icon_bi()`
 * (a bootstrap-icons suffix or ''), matching the contract
 * `neccospeak_frame_cta_items_bi()` established for this file's consumers —
 * never re-map the result.
 *
 * NOT gated on `cta_enabled`: the option governs the CTA *cards*, and
 * `neccospeak_frame_cta_primary_secondary()` applies that gate itself. The
 * quick-action chips built from `type === 'message'` rows deliberately ignore
 * it — the ported production "Quick actions" editor
 * (includes/agent-ui-tabs/tab-flow.php) has no `cta_enabled` control on
 * screen at all (it is hidden there, §6), so gating chips on an invisible,
 * default-false toggle would make that editor look broken.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<int,array{label:string,icon:string,type:string,value:string,subtitle:string,slot:string,order:int}>
 */
function neccospeak_frame_cta_rows( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$raw  = ( isset( $opts['cta_list'] ) && is_array( $opts['cta_list'] ) ) ? $opts['cta_list'] : array();

	$rows = array();
	foreach ( $raw as $row ) {
		// Defensive, same discipline as neccospeak_widget_cta_items(): never
		// trust the stored shape blindly. label+type are the two columns every
		// consumer below dereferences.
		if ( ! is_array( $row ) || ! isset( $row['label'], $row['type'] ) || ! is_scalar( $row['label'] ) || ! is_scalar( $row['type'] ) ) {
			continue;
		}
		$label = (string) $row['label'];
		if ( '' === trim( $label ) ) {
			continue;
		}
		// An ABSENT 'enabled' key means "row stored before dev.37" -> live.
		if ( array_key_exists( 'enabled', $row ) && empty( $row['enabled'] ) ) {
			continue;
		}
		$slot_raw = ( isset( $row['slot'] ) && is_scalar( $row['slot'] ) ) ? (string) $row['slot'] : '';
		$rows[]   = array(
			'label'    => $label,
			'icon'     => neccospeak_frame_chip_icon_bi( ( isset( $row['icon'] ) && is_scalar( $row['icon'] ) ) ? (string) $row['icon'] : '' ),
			'type'     => (string) $row['type'],
			'value'    => ( isset( $row['value'] ) && is_scalar( $row['value'] ) ) ? (string) $row['value'] : '',
			'subtitle' => ( isset( $row['subtitle'] ) && is_scalar( $row['subtitle'] ) ) ? (string) $row['subtitle'] : '',
			'slot'     => ( 'main' === $slot_raw ) ? 'main' : 'sub',
			// Missing 'order' (pre-dev.37 row) -> its own stored position, so
			// an un-migrated list keeps exactly the order it renders in today.
			'order'    => ( isset( $row['order'] ) && is_scalar( $row['order'] ) ) ? (int) $row['order'] : count( $rows ),
		);
	}

	// Explicit position tie-break rather than relying on usort()'s stability
	// (guaranteed since PHP 8.0, which is this plugin's floor — but two rows
	// sharing an `order` value should sort deterministically by intent, not by
	// implementation detail).
	$positions = array_keys( $rows );
	usort(
		$positions,
		function ( $a, $b ) use ( $rows ) {
			if ( $rows[ $a ]['order'] !== $rows[ $b ]['order'] ) {
				return $rows[ $a ]['order'] < $rows[ $b ]['order'] ? -1 : 1;
			}
			return $a < $b ? -1 : ( $a > $b ? 1 : 0 );
		}
	);
	$sorted = array();
	foreach ( $positions as $position ) {
		$sorted[] = $rows[ $position ];
	}
	return $sorted;
}

/**
 * Whether `cta_list` contains ANY `type === 'message'` row with a label —
 * including rows switched off with `enabled === false`, which
 * `neccospeak_frame_cta_rows()` has already dropped by the time its caller
 * sees them.
 *
 * This is the discriminator for the quick-action-chip fallback (§3): "no
 * message rows configured at all" falls back to the legacy
 * `home_capabilities` chips, while "message rows exist but every one of them
 * is switched off" renders no chips, because that is what the admin asked
 * for. Without this distinction, disabling the last chip would silently
 * resurrect a completely different, older chip list.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return bool
 */
function neccospeak_frame_cta_has_message_row( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$raw  = ( isset( $opts['cta_list'] ) && is_array( $opts['cta_list'] ) ) ? $opts['cta_list'] : array();
	foreach ( $raw as $row ) {
		if ( ! is_array( $row ) || ! isset( $row['label'], $row['type'] ) || ! is_scalar( $row['label'] ) || ! is_scalar( $row['type'] ) ) {
			continue;
		}
		if ( 'message' === (string) $row['type'] && '' !== trim( (string) $row['label'] ) ) {
			return true;
		}
	}
	return false;
}

/**
 * 0.3.0-dev.37 (会話フロー本番パリティ フェーズ1 §3) — the quick-action chips,
 * in ONE shape used by both server-rendered `#quick-actions` markup
 * (`neccospeak_frame_html()`) and `window.__AGENT_CONFIG__.quick_actions`
 * (`neccospeak_frame_client_config()`), so the two can never disagree again.
 *
 * Source of truth is now `cta_list`'s `type === 'message'` rows (the rows the
 * ported production "Quick actions" editor edits), enabled-only, in `order`
 * ascending: `action` is the text actually sent on click (the row's `value`,
 * i.e. the production "送信メッセージ" column), `label` is what the button
 * shows, `icon` is a bootstrap-icons suffix or ''.
 *
 * Back-compat (§3): when `cta_list` holds no message row at all, the legacy
 * `home_capabilities` chips (`neccospeak_widget_home_chips()`, the pre-dev.37
 * source) are used unchanged — including its own "the chip's label IS the
 * question" rule, i.e. `action === label` for those. This keeps every install
 * that configured chips through the old textarea rendering exactly as before.
 * A `value`-less message row degrades the same way, for the same reason.
 *
 * The 6-item clamp is inherited from `neccospeak_widget_home_chips()` and
 * applies to both sources.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<int,array{label:string,icon:string,action:string}>
 */
function neccospeak_frame_chips( $opts ) {
	$opts  = is_array( $opts ) ? $opts : array();
	$chips = array();

	foreach ( neccospeak_frame_cta_rows( $opts ) as $row ) {
		if ( 'message' !== $row['type'] ) {
			continue;
		}
		$chips[] = array(
			'label'  => $row['label'],
			'icon'   => $row['icon'],
			'action' => ( '' !== $row['value'] ) ? $row['value'] : $row['label'],
		);
		if ( count( $chips ) >= 6 ) {
			break;
		}
	}

	if ( ! empty( $chips ) || neccospeak_frame_cta_has_message_row( $opts ) ) {
		return $chips;
	}

	$legacy = function_exists( 'neccospeak_widget_home_chips' ) ? neccospeak_widget_home_chips( $opts ) : array();
	if ( ! is_array( $legacy ) ) {
		return array();
	}
	foreach ( $legacy as $chip ) {
		if ( ! is_array( $chip ) ) {
			continue;
		}
		$label   = isset( $chip['label'] ) ? (string) $chip['label'] : '';
		$chips[] = array(
			'label'  => $label,
			'icon'   => neccospeak_frame_chip_icon_bi( isset( $chip['icon'] ) ? (string) $chip['icon'] : '' ),
			'action' => $label,
		);
		if ( count( $chips ) >= 6 ) {
			break;
		}
	}
	return $chips;
}

/**
 * `neccospeak_widget_cta_items( $opts )` (includes/frontend-config.php, a
 * "do not touch" shared file, §6.1), with every item's `icon` re-mapped
 * through `neccospeak_frame_chip_icon_bi()`. See that function's docblock —
 * `neccospeak_widget_cta_items()` returns the same raw NS icon-name enum as
 * `neccospeak_widget_home_chips()`, so it needs the identical treatment
 * before reaching `window.__AGENT_CONFIG__.cta.items`.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<int,array<string,mixed>>
 */
function neccospeak_frame_cta_items_bi( $opts ) {
	$items = function_exists( 'neccospeak_widget_cta_items' ) ? neccospeak_widget_cta_items( $opts ) : array();
	if ( ! is_array( $items ) ) {
		return array();
	}
	foreach ( $items as $index => $item ) {
		if ( is_array( $item ) && isset( $item['icon'] ) ) {
			$items[ $index ]['icon'] = neccospeak_frame_chip_icon_bi( (string) $item['icon'] );
		}
	}
	return $items;
}

/**
 * Builds a single CTA item's clickable target for the shape consumed by
 * `renderWelcomeCtas()` (`openai-realtime-agent/widget-app-persistent.js`):
 * `url` (a plain URL, or a `tel:`/`mailto:` URI — that function itself
 * regex-tests for the `tel:`/`mailto:` prefix to decide whether to
 * `window.open()` or `_postToParent({type:'navigate'})`) plus `action_type`
 * (only `'voice'` is special-cased there; anything else falls through to the
 * `url` check).
 *
 * NS's `cta_list` type enum (`link`/`message`/`tel`/`mail`/`cf7_prefill`/
 * `voice`, `includes/settings.php`'s `neccospeak_sanitize_cta_list()`) is
 * broader than what the copied production renderer understands — `'voice'`
 * (storable since 0.3.0-dev.14) maps to the renderer's special-cased voice
 * action (`switchToRealtimeMode()`), while `message`/`cf7_prefill` have no
 * equivalent in the production primary/secondary shape (D1: this file must
 * not invent new client behavior to support them). Those two degrade to a
 * non-clickable card (empty `url`, non-`'voice'` `action_type`) rather than
 * a broken link.
 *
 * @param array $item One `neccospeak_frame_cta_items_bi()` entry.
 * @return array{url:string,action_type:string}
 */
function neccospeak_frame_cta_target( $item ) {
	$type  = ( is_array( $item ) && isset( $item['type'] ) ) ? (string) $item['type'] : '';
	$value = ( is_array( $item ) && isset( $item['value'] ) ) ? (string) $item['value'] : '';
	if ( 'voice' === $type ) {
		return array(
			'url'         => '',
			'action_type' => 'voice',
		);
	}
	$url = '';
	if ( 'link' === $type ) {
		$url = $value;
	} elseif ( 'tel' === $type && '' !== $value ) {
		$url = 'tel:' . $value;
	} elseif ( 'mail' === $type && '' !== $value ) {
		$url = 'mailto:' . $value;
	}
	return array(
		'url'         => $url,
		'action_type' => 'link',
	);
}

/**
 * Maps the CTA rows onto the `cta.primary` / `cta.secondary` shape that
 * `renderWelcomeCtas()` actually reads (design doc §4.2.1 "cta" row's 2026-08
 * supply-side fix).
 *
 * 0.3.0-dev.37 (会話フロー本番パリティ フェーズ1 §4) changed the supply side from
 * `neccospeak_frame_cta_items_bi()` to `neccospeak_frame_cta_rows()` so the
 * four new `cta_list` columns are visible here (that helper's docblock explains
 * why the shared-file path cannot carry them), and with them:
 *
 *   - `enabled === false` rows never reach a card at all (dropped upstream in
 *     neccospeak_frame_cta_rows()).
 *   - rows are consumed in `order` ascending, not stored order.
 *   - `primary` is the first `slot === 'main'` row. With no 'main' row the
 *     pre-dev.37 rule still applies verbatim (the first row wins), which is
 *     what keeps every list stored before dev.37 rendering identically —
 *     `slot` defaults to 'sub' for all of them.
 *   - `primary.subtitle` is the row's own `subtitle` column. The fixed
 *     "Microphone permission…" line stays as a FALLBACK for a voice row whose
 *     subtitle is empty (that string was invented here in dev.14 precisely
 *     because the row shape had nowhere to put one); a voice row with a real
 *     subtitle now shows the admin's own words. Secondary rows have no
 *     subtitle slot in the production renderer, so theirs is not emitted.
 *   - `type === 'message'` rows are EXCLUDED entirely: since §3 they are the
 *     quick-action chips' data source, and a row cannot sensibly be both a
 *     chip and a CTA card. Before dev.37 they reached this function and
 *     degraded to a dead, non-clickable card (neccospeak_frame_cta_target()
 *     returns an empty url for them), so dropping them removes broken output
 *     rather than working output.
 *
 * `primary.enabled` additionally requires `cta_home_enabled` (the existing
 * "show on the home/welcome screen" toggle, §4.2.1) — `cta_enabled` alone
 * governs whether CTAs exist at all, not where they render. `cta_enabled`
 * itself is still a hard supply-side gate here (no rows at all when off),
 * exactly as `neccospeak_widget_cta_items()` enforced before dev.37: a
 * disabled-CTA install must not start leaking its labels/URLs into the
 * client config.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array{primary:array,secondary:array<int,array>}
 */
function neccospeak_frame_cta_primary_secondary( $opts ) {
	$opts        = is_array( $opts ) ? $opts : array();
	$cta_enabled = ! empty( $opts['cta_enabled'] );
	$home_shown  = array_key_exists( 'cta_home_enabled', $opts ) ? (bool) $opts['cta_home_enabled'] : true;
	$items       = $cta_enabled ? neccospeak_frame_cta_rows( $opts ) : array();

	// 'message' rows are chips now (§3), never cards. 'voice' rows are only
	// meaningful when the voice feature itself is on — otherwise the click
	// would hand the user to a mode the install cannot serve (supply-side
	// drop, same D1 discipline as the type degradation in
	// neccospeak_frame_cta_target()). Remaining rows shift up in both cases.
	$voice_ok = ! empty( $opts['voice_enabled'] );
	$items    = array_values(
		array_filter(
			$items,
			function ( $item ) use ( $voice_ok ) {
				$type = isset( $item['type'] ) ? (string) $item['type'] : '';
				if ( 'message' === $type ) {
					return false;
				}
				return $voice_ok || 'voice' !== $type;
			}
		)
	);

	$primary = array(
		'enabled'     => false,
		'text'        => '',
		'subtitle'    => '',
		'icon'        => '',
		'url'         => '',
		'action_type' => 'link',
	);
	$secondary = array();

	if ( ! empty( $items ) ) {
		// slot==='main' wins the primary card; with none, the pre-dev.37 rule
		// (first row) applies.
		$primary_index = 0;
		foreach ( $items as $candidate_index => $candidate ) {
			if ( isset( $candidate['slot'] ) && 'main' === $candidate['slot'] ) {
				$primary_index = $candidate_index;
				break;
			}
		}
		$first = $items[ $primary_index ];
		unset( $items[ $primary_index ] );
		$items = array_values( $items );

		$first_target = neccospeak_frame_cta_target( $first );
		$first_icon   = isset( $first['icon'] ) ? (string) $first['icon'] : '';
		if ( '' === $first_icon && 'voice' === $first_target['action_type'] ) {
			// Production's voice CTA card always carries the mic glyph; a
			// voice row saved with icon 'none' would otherwise render bare.
			// $items icons are already bootstrap-icons suffixes here
			// (neccospeak_frame_cta_rows()), hence 'mic-fill' not 'mic'.
			$first_icon = 'mic-fill';
		}
		$first_subtitle = isset( $first['subtitle'] ) ? (string) $first['subtitle'] : '';
		if ( '' === $first_subtitle && 'voice' === $first_target['action_type'] ) {
			$first_subtitle = __( 'Microphone permission will be required', 'neccospeak' );
		}
		$primary = array(
			'enabled'     => $cta_enabled && $home_shown,
			'text'        => isset( $first['label'] ) ? (string) $first['label'] : '',
			'subtitle'    => $first_subtitle,
			'icon'        => $first_icon,
			'url'         => $first_target['url'],
			'action_type' => $first_target['action_type'],
		);
		foreach ( $items as $item ) {
			$target    = neccospeak_frame_cta_target( $item );
			$item_icon = isset( $item['icon'] ) ? (string) $item['icon'] : '';
			if ( '' === $item_icon && 'voice' === $target['action_type'] ) {
				$item_icon = 'mic-fill';
			}
			$secondary[] = array(
				'text'        => isset( $item['label'] ) ? (string) $item['label'] : '',
				'icon'        => $item_icon,
				'url'         => $target['url'],
				'action_type' => $target['action_type'],
			);
		}
	}

	return array(
		'primary'   => $primary,
		'secondary' => $secondary,
	);
}

/**
 * NS launcher icon name → a complete `<svg>` string for
 * `window.NECCOSPEAK_LAUNCHER.launcher.icon_svg` (design doc §4.3's
 * `launcher.icon_svg` row: "assets/js/widget-core.jsのL170-200のパス定義を
 * PHPのインラインSVGテーブルに移植"). Path data is copied verbatim from that
 * file's own `ICON_PATHS` table — original artwork drawn for NeccoSpeak
 * (that file's own header comment: "no third-party icon set is embedded"),
 * not third-party. Duplicated here rather than requiring
 * assets/js/widget-core.js itself, which is a front-end-only asset never
 * loaded server-side. The attribute set (viewBox/stroke/etc.) mirrors that
 * same file's `svgIcon()` builder exactly, so widget-loader.js's
 * `sanitizeSvg()` (which parses this string as `image/svg+xml`) produces a
 * pixel-identical icon.
 *
 * @param string $icon One of 'chat'|'bubbles'|'help'|'sparkle'|'headset'|'mic'.
 * @return string A complete `<svg>…</svg>` string, or '' for an unknown name
 *                 (including 'custom', which uses `icon_image_url` instead).
 */
function neccospeak_frame_launcher_icon_svg( $icon ) {
	$paths = array(
		'chat'    => array(
			'M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z',
		),
		'mic'     => array(
			'M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z',
			'M19 10v2a7 7 0 0 1-14 0v-2',
			'M12 19v4',
			'M8 23h8',
		),
		'bubbles' => array(
			'M3 6a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H8l-4 3v-3H5a2 2 0 0 1-2-2z',
			'M18 8h1a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1v3l-3-3h-3',
		),
		'help'    => array(
			'M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0',
			'M9.5 9.4a2.6 2.6 0 1 1 3.4 2.5c-.6.2-.9.8-.9 1.4v.5',
			'M12 17h.01',
		),
		'sparkle' => array(
			'M12 3l1.8 4.7L18.5 9.5 13.8 11.3 12 16l-1.8-4.7L5.5 9.5l4.7-1.8z',
			'M18.6 15.4l.7 1.9 1.9.7-1.9.7-.7 1.9-.7-1.9-1.9-.7 1.9-.7z',
		),
		'headset' => array(
			'M4 14v-2a8 8 0 0 1 16 0v2',
			'M4 14h2a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1z',
			'M20 14h-2a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1z',
			'M20 19v1a2 2 0 0 1-2 2h-4',
		),
	);
	if ( ! isset( $paths[ $icon ] ) ) {
		return '';
	}
	$d = '';
	foreach ( $paths[ $icon ] as $path ) {
		$d .= '<path d="' . esc_attr( $path ) . '"/>';
	}
	// xmlns is REQUIRED: widget-loader.js's sanitizeSvg() parses this string
	// with DOMParser('image/svg+xml') — strict XML, no HTML-parser namespace
	// magic — so without the declaration the element lands in the null
	// namespace and the browser silently renders nothing (empty launcher
	// circle, found 2026-08-07).
	return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $d . '</svg>';
}

/*
-------------------------------------------------------------------------
 * 4.2.1 — window.__AGENT_CONFIG__.
 * ----------------------------------------------------------------------
 */

/**
 * Builds `window.__AGENT_CONFIG__` (design doc §4.2.1 — the full key table).
 * The variable name `__AGENT_CONFIG__` itself is frozen (not renamed to
 * something NS-flavored) specifically so the copied
 * `openai-realtime-agent/widget-app-persistent.js`'s `get cfg()` needs no
 * change (design doc §4.2's own note).
 *
 * `memory_enabled` is deliberately ABSENT (not `false` — the key itself does
 * not exist), `agentTenant`/`visitorId`/`visitorIdIsNew` are not part of
 * this shape at all (design doc §4.2 — those three are dropped outright,
 * not renamed), and `realtime_instructions`/`realtime_prompt` are always
 * empty/null (NS's `/realtime-token` REST route confirms instructions
 * server-side already — design doc §3.2 (2)'s "重要な整合点").
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array
 */
function neccospeak_frame_client_config( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$b    = function ( $key, $default ) use ( $opts ) {
		return array_key_exists( $key, $opts ) ? (bool) $opts[ $key ] : (bool) $default;
	};
	$i    = function ( $key, $default ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (int) $opts[ $key ] : (int) $default;
	};
	$s    = function ( $key, $default ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (string) $opts[ $key ] : (string) $default;
	};

	$primary      = $s( 'brand_color', '#2f6fed' );
	$primary      = neccospeak_frame_is_hex_color( $primary ) ? $primary : '#2f6fed';
	$primary_dark = neccospeak_frame_darken( $primary, 13 );
	if ( '' === $primary_dark ) {
		$primary_dark = $primary;
	}

	$header_logo_url = function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'header_logo_id', 'medium' ) : '';
	$home_avatar_url = function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'home_avatar_id', 'thumbnail' ) : '';

	$page_id = 0;
	if ( $b( 'page_context_enabled', false ) && function_exists( 'neccospeak_page_context_current_id' ) ) {
		$page_id = (int) neccospeak_page_context_current_id();
	}

	$home_contact_url   = function_exists( 'neccospeak_widget_safe_url' ) ? neccospeak_widget_safe_url( $s( 'fallback_contact_url', '' ) ) : '';
	$home_contact_phone = function_exists( 'neccospeak_widget_safe_phone' ) ? neccospeak_widget_safe_phone( $s( 'fallback_contact_phone', '' ) ) : '';

	$welcome_headline = $s( 'home_headline', '' );
	$welcome_body     = $s( 'home_body', '' );
	if ( '' === $welcome_body ) {
		$welcome_body = $s( 'welcome_message', '' );
	}

	$home_icon            = $s( 'home_icon', 'none' );
	$home_icon_bi         = neccospeak_frame_welcome_icon_bi( $home_icon );
	$home_icon_custom_url = ( 'custom' === $home_icon ) ? ( function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'home_icon_id', 'medium' ) : '' ) : '';

	$capability_tags = function_exists( 'neccospeak_widget_home_capability_tags' ) ? neccospeak_widget_home_capability_tags( $opts ) : array();

	// Quick-action chips, reshaped into pocxr's own {action,icon,text} shape
	// (design doc §5.1's ported EJS quick-action loop / widget-app-persistent.js's
	// updateQuickActions()) — 'action' is the literal text sent when clicked.
	// 0.3.0-dev.37 (§3): both this and the server-rendered #quick-actions DOM
	// now come from the SINGLE neccospeak_frame_chips() source (cta_list's
	// message rows, with the legacy home_capabilities fallback), so the two
	// renderings can no longer drift apart.
	$quick_items = array();
	foreach ( neccospeak_frame_chips( $opts ) as $chip ) {
		$quick_items[] = array(
			'action' => $chip['action'],
			'icon'   => $chip['icon'],
			'text'   => $chip['label'],
		);
	}

	$panel_radius  = max( 0, min( 32, $i( 'theme_panel_radius', 16 ) ) );
	$bubble_radius = max( 0, min( 24, $i( 'theme_bubble_radius', 16 ) ) );
	$button_radius = max( 0, min( 24, $i( 'theme_button_radius', 8 ) ) );
	$font_size     = max( 12, min( 20, $i( 'theme_font_size', 15 ) ) );

	// 0.3.0 i18n ('locale' below): the site-specific FACTS the frame's JS used
	// to hard-code — the clock was pinned to UTC+9 and the default page-context
	// label to a brand name. Every WP call is function_exists()-guarded like
	// the rest of this file (a partial bootstrap must never fatal here), and
	// the gmt_offset option is the documented fallback when wp_timezone() is
	// unavailable; a site with neither is treated as UTC.
	$site_lang         = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'language' ) : '';
	$site_name         = function_exists( 'get_bloginfo' ) ? (string) get_bloginfo( 'name' ) : '';
	$tz_offset_minutes = 0;
	$tz_abbr           = '';
	if ( function_exists( 'wp_timezone' ) ) {
		$site_tz           = wp_timezone();
		$now_utc           = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
		$tz_offset_minutes = (int) round( $site_tz->getOffset( $now_utc ) / 60 );
		// 'T' yields the zone's own abbreviation ('JST' for Asia/Tokyo, 'UTC'
		// for UTC, '+09:00' for a bare-offset setting) — appended to the clock
		// reading the greeting prompt shows the model, which previously always
		// said 'JST'.
		$tz_abbr = (string) $now_utc->setTimezone( $site_tz )->format( 'T' );
	} elseif ( function_exists( 'get_option' ) ) {
		$tz_offset_minutes = (int) round( (float) get_option( 'gmt_offset', 0 ) * 60 );
	}

	return array(
		'ui'                              => array(
			'header_title'       => $s( 'widget_title', __( 'AI Assistant', 'neccospeak' ) ),
			'header_logo_url'    => $header_logo_url,
			'header_logo_height' => 24,
			'header_show_status' => $b( 'home_status_indicator', true ),
			'status_messages'    => array(),
			'header_nav_links'   => array(),
			'avatar_emoji'       => $home_icon_bi,
			'avatar_image_url'   => $home_avatar_url,
			'input_placeholder'  => $s( 'home_input_placeholder', '' ),
			'input_max_length'   => 500,
			'show_voice_button'  => $b( 'voice_enabled', false ) && $b( 'show_voice_button', true ),
			'primary_color'      => $primary,
			'primary_color_dark' => $primary_dark,
		),
		'theme'                           => array(
			'background'              => $s( 'theme_panel_bg', '' ),
			'background_chat'         => $s( 'theme_thread_bg', '' ),
			'text'                    => $s( 'theme_panel_text', '' ),
			'text_muted'              => $s( 'theme_panel_muted', '' ),
			'border'                  => $s( 'theme_panel_border', '' ),
			'header_bg'               => $s( 'theme_header_bg', '' ),
			'header_text'             => $s( 'theme_header_text', '' ),
			'user_bubble_bg'          => $s( 'theme_bubble_user_bg', '' ),
			'user_bubble_text'        => $s( 'theme_bubble_user_text', '' ),
			'bot_bubble_bg'           => $s( 'theme_bubble_bot_bg', '' ),
			'bot_bubble_text'         => $s( 'theme_bubble_bot_text', '' ),
			'radius'                  => $panel_radius,
			'bubble_radius'           => $bubble_radius,
			'button_radius'           => $button_radius,
			'font_family'             => $s( 'theme_font_family', 'system' ),
			'font_size'               => $font_size,
			'chat_background_pattern' => 'none',
			'custom_css'              => $s( 'custom_css', '' ),
		),
		'welcome'                         => array(
			'icon_image_url'    => $home_avatar_url,
			'icon'              => $home_icon_bi,
			'icon_image'        => $home_icon_custom_url,
			'title'             => ( '' !== $welcome_headline ) ? $welcome_headline : __( 'Hello!', 'neccospeak' ),
			'subtitle'          => ( '' !== $welcome_body ) ? $welcome_body : __( 'I can help with our services and how to get started. Feel free to ask anything.', 'neccospeak' ),
			'show_capabilities' => ! empty( $capability_tags ),
			'capabilities'      => $capability_tags,
		),
		// {default:{items:[…]}} per design doc §4.2.1's documented shape.
		// Note: the copied widget-app-persistent.js only actually reads
		// `cfg.quick_actions.show_icons` / `.max_visible` at runtime (both
		// left absent here, taking their safe built-in defaults — show icons,
		// show every chip) — `default.items` itself is not consumed by that
		// file (the client's *initial* chips instead come from the PHP-
		// rendered `#quick-actions` DOM, design doc §4.2's `quickActions` row).
		// Both are supplied so neither reading of the contract is left unmet.
		'quick_actions'                   => array(
			'default' => array( 'items' => $quick_items ),
		),
		'cards'                           => array(),
		'loading'                         => array(
			'style' => ( 'skeleton' === $s( 'loading_style', 'dots' ) ) ? 'skeleton' : 'dots',
			'lines' => 3,
		),
		// 0.3.0-dev.47: session-persistence.js (sessionStorage 復元) が読む。
		// 従来は復元 TTL が 30 秒ハードコードで、管理画面の「復元有効期間
		// (session_ttl_minutes, 既定 30 分)」が frame 側で無視されていた。
		'session'                         => array(
			'enabled'      => $b( 'session_restore_enabled', true ),
			'ttl_minutes'  => max( 1, (int) $s( 'session_ttl_minutes', '30' ) ),
			'max_messages' => max( 1, (int) $s( 'session_max_messages', '30' ) ),
		),
		'animations'                      => array(),
		// 'primary'/'secondary' are what renderWelcomeCtas() (widget-app-
		// persistent.js) actually reads (2026-08 supply-side fix, §4.2.1's
		// "cta" row) — derived from cta_list via
		// neccospeak_frame_cta_primary_secondary(). 'items' is kept for
		// back-compat (no current consumer, but cheap to leave in place).
		'cta'                             => array_merge(
			array(
				'enabled' => $b( 'cta_enabled', false ),
				'home'    => $b( 'cta_home_enabled', true ),
				'chat'    => $b( 'cta_chat_enabled', true ),
				'heading' => $s( 'cta_heading', '' ),
				'items'   => neccospeak_frame_cta_items_bi( $opts ),
			),
			neccospeak_frame_cta_primary_secondary( $opts )
		),
		'trust'                           => array(),
		// 2026-08 撤去記録: ここにあった 'feedback' サブツリー（enabled / comment /
		// includeMessage / contact_url）は、AI応答評価機能を無料版から提供しない
		// 製品判断により、widget-app-persistent.js 側の評価 UI ごと削除した。
		'page_contexts'                   => array(
			'default' => array(),
			'pages'   => array(),
		),
		'page_id'                         => $page_id,
		'realtime_instructions'           => null,
		'realtime_voice'                  => $s( 'voice', 'alloy' ),
		'realtime_transcription_language' => $s( 'transcription_language', 'auto' ),
		'realtime_turn_eagerness'         => $s( 'turn_eagerness', 'auto' ),
		'realtime_prompt'                 => '',
		'default_mode'                    => 'text',
		'tools_enabled'                   => $b( 'tools_enabled', false ),
		// 0.3.0-dev.37 (§5): url_map used to be a hard-coded empty array, which
		// made the voice navigate_page tool answer "Invalid Category" for every
		// possible input (tool/tool-handler.js's handleNavigatePage()). It is
		// now the published-page list, keyed by page title — the SAME builder
		// includes/rest.php uses for the tool definition's `category` enum, so
		// the model can only ever ask for a key that exists here. Empty (i.e.
		// pre-dev.37 behavior) unless nav_list_enabled is on. `site_url` is
		// what the client re-joins each entry's relative `path` onto.
		'navigation'                      => array(
			'site_url' => function_exists( 'home_url' ) ? home_url( '/' ) : '/',
			'url_map'  => function_exists( 'neccospeak_page_context_nav_url_map' ) ? neccospeak_page_context_nav_url_map( $opts ) : array(),
		),
		// 'memory_enabled' intentionally absent (design doc §4.2 row).
		'voiceUi'                         => array(
			'enabled'      => $b( 'voice_enabled', false ) && $b( 'voice_ui_enabled', true ),
			'visualizer'   => $s( 'voice_ui_visualizer', 'bars' ),
			'mute'         => $b( 'voice_ui_mute_button', true ),
			'deviceSelect' => $b( 'voice_ui_device_select', true ),
			'textReturn'   => $b( 'voice_ui_text_return', true ),
		),
		'stream'                          => array(
			'mode'  => function_exists( 'neccospeak_stream_mode' ) ? neccospeak_stream_mode( $opts ) : 'off',
			'speed' => $s( 'stream_speed', 'normal' ),
			'skip'  => $b( 'stream_skip_enabled', true ),
		),
		'resilience'                      => array(
			'timeoutMs'    => max( 10000, min( 240000, $i( 'chat_timeout_seconds', 130 ) * 1000 ) ),
			'contactUrl'   => $home_contact_url,
			'contactLabel' => $s( 'fallback_contact_label', '' ),
		),
		'analytics'                       => array(
			'enabled' => $b( 'analytics_enabled', false ),
			'events'  => function_exists( 'neccospeak_widget_analytics_events' ) ? neccospeak_widget_analytics_events( $opts ) : array(),
		),
		'disclaimer'                      => array(
			'inlineEnabled' => $b( 'disclaimer_inline_enabled', true ),
			'inlineText'    => $s( 'disclaimer_inline_text', '' ),
		),
		'contact'                         => array(
			'url'   => $home_contact_url,
			'label' => $s( 'fallback_contact_label', '' ),
			'phone' => $home_contact_phone,
		),
		// 0.3.0 i18n: the frame's JS derives every locale-dependent value it
		// used to hard-code (JST-fixed clock, hard-coded brand label) from
		// this block instead — see prompt/greeting-time-hint.js and
		// widget/navigation-context.js. Strings themselves stay in
		// neccospeak_frame_i18n() (§4.4); only site-specific FACTS live here.
		'locale'                          => array(
			'lang'              => $site_lang,
			'tz_offset_minutes' => $tz_offset_minutes,
			'tz_abbr'           => $tz_abbr,
			'site_name'         => $site_name,
		),
	);
}

/*
-------------------------------------------------------------------------
 * 4.3 — window.NECCOSPEAK_LAUNCHER.
 * ----------------------------------------------------------------------
 */

/**
 * Builds `window.NECCOSPEAK_LAUNCHER` (design doc §4.3 — the pocxr
 * `/api/widget-config` response shape, so widget-loader.js's
 * `applyLauncherConfig()` needs no change to consume it). Consumed by
 * `neccospeak_enqueue()` in neccospeak.php via `wp_localize_script()` onto
 * the PARENT page's loader script — this is the launcher/parent-page config,
 * not the framed document's own (see neccospeak_frame_client_config() for
 * that).
 *
 * Reuses includes/frontend-config.php's neccospeak_widget_launcher() for
 * every already-validated field rather than re-deriving the same
 * enum/clamp rules a second time, then reshapes its keys into pocxr's own
 * vocabulary.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array
 */
function neccospeak_frame_launcher_config( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	$s    = function ( $key, $default ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (string) $opts[ $key ] : (string) $default;
	};

	$launcher = function_exists( 'neccospeak_widget_launcher' ) ? neccospeak_widget_launcher( $opts ) : array();
	$launcher = is_array( $launcher ) ? $launcher : array();

	$size_px_map = array(
		's' => 40,
		'm' => 48,
		'l' => 56,
	);
	$size_key    = isset( $launcher['size'] ) ? $launcher['size'] : 'm';
	$size_px     = isset( $size_px_map[ $size_key ] ) ? $size_px_map[ $size_key ] : 48;

	$position_is_left = ( isset( $launcher['position'] ) && 'bottom-left' === $launcher['position'] );
	$offset_x         = isset( $launcher['offsetX'] ) ? (int) $launcher['offsetX'] : 20;
	$offset_y         = isset( $launcher['offsetY'] ) ? (int) $launcher['offsetY'] : 20;
	$position         = array( 'bottom' => $offset_y );
	if ( $position_is_left ) {
		$position['left'] = $offset_x; // §2.6's "L" addition: widget-loader.js maps this to right:auto; left:….
	} else {
		$position['right'] = $offset_x;
	}

	$shape = ( isset( $launcher['shape'] ) && 'rounded' === $launcher['shape'] ) ? 'pill' : 'circle';

	$label = isset( $launcher['label'] ) ? (string) $launcher['label'] : '';
	if ( '' === $label ) {
		$label = __( 'Ask AI', 'neccospeak' );
	}

	$tooltip_text = isset( $launcher['tooltip'] ) ? (string) $launcher['tooltip'] : '';

	$icon_key       = isset( $launcher['icon'] ) ? $launcher['icon'] : 'chat';
	$is_custom_icon = ( 'custom' === $icon_key );
	$icon_svg       = $is_custom_icon ? '' : neccospeak_frame_launcher_icon_svg( $icon_key );
	$icon_image_url = $is_custom_icon && isset( $launcher['iconUrl'] ) ? (string) $launcher['iconUrl'] : '';

	$mobile              = ( isset( $launcher['mobile'] ) && is_array( $launcher['mobile'] ) ) ? $launcher['mobile'] : array();
	$mobile_hide         = ! empty( $mobile['hide'] );
	$mobile_size_key     = isset( $mobile['size'] ) ? $mobile['size'] : 'inherit';
	$mobile_size_px      = isset( $size_px_map[ $mobile_size_key ] ) ? $size_px_map[ $mobile_size_key ] : $size_px;
	$mobile_position_key = isset( $mobile['position'] ) ? $mobile['position'] : 'inherit';
	$mobile_is_left      = ( 'bottom-left' === $mobile_position_key ) || ( 'inherit' === $mobile_position_key && $position_is_left );
	$mobile_offset_x     = isset( $mobile['offsetX'] ) ? (int) $mobile['offsetX'] : $offset_x;
	$mobile_offset_y     = isset( $mobile['offsetY'] ) ? (int) $mobile['offsetY'] : $offset_y;
	$mobile_position     = array( 'bottom' => $mobile_offset_y );
	if ( $mobile_is_left ) {
		$mobile_position['left'] = $mobile_offset_x;
	} else {
		$mobile_position['right'] = $mobile_offset_x;
	}

	$contact_url = function_exists( 'neccospeak_widget_safe_url' ) ? neccospeak_widget_safe_url( $s( 'fallback_contact_url', '' ) ) : '';

	$version    = defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '';
	$home       = function_exists( 'home_url' ) ? home_url( '/' ) : '/';
	$frame_path = $home . '?neccospeak_frame=1&v=' . rawurlencode( $version );

	// ui.theme_overrides (0.3.0-dev.35): the frame JS's theme-config handler
	// unconditionally repaints --agent-user-bg with ui.primary_color
	// (widget-app-persistent.js _applyThemeConfig()), then refines from
	// themeOverrides.colors — production's SaaS config always supplied that
	// refinement, but NS never did, so an admin's explicit
	// theme_bubble_user_bg was stomped by the brand color about a second
	// after the frame loaded. Emit the explicitly-set theme colors here in
	// the shape _applyThemeOverrides() expects (zero-emission: only keys the
	// admin actually set; --agent-footer-bg mirrors theme_panel_bg exactly
	// like neccospeak_frame_theme_css() does for the SSR :root).
	$override_color_map = array(
		'theme_panel_bg'         => 'background',
		'theme_thread_bg'        => 'background_chat',
		'theme_panel_text'       => 'text',
		'theme_panel_muted'      => 'text_muted',
		'theme_panel_border'     => 'border',
		'theme_bubble_user_bg'   => 'user_bubble_bg',
		'theme_bubble_user_text' => 'user_bubble_text',
		'theme_bubble_bot_bg'    => 'bot_bubble_bg',
		'theme_bubble_bot_text'  => 'bot_bubble_text',
	);
	$override_colors    = array();
	foreach ( $override_color_map as $override_opt => $override_key ) {
		$override_val = $s( $override_opt, '' );
		if ( neccospeak_frame_is_hex_color( $override_val ) ) {
			$override_colors[ $override_key ] = $override_val;
		}
	}
	if ( isset( $override_colors['background'] ) ) {
		$override_colors['footer_bg'] = $override_colors['background'];
	}

	$ui_config = array(
		'primary_color' => $s( 'brand_color', '#2f6fed' ),
		'header_title'  => $s( 'widget_title', __( 'AI Assistant', 'neccospeak' ) ),
	);
	if ( ! empty( $override_colors ) ) {
		$ui_config['theme_overrides'] = array( 'colors' => $override_colors );
	}

	return array(
		'ui'          => $ui_config,
		'launcher'    => array(
			'size'                => $size_px,
			'icon_size'           => 24,
			'position'            => $position,
			'shape'               => $shape,
			'hide_label'          => empty( $launcher['showLabel'] ),
			'label'               => $label,
			'tooltip'             => array(
				'enabled' => ( '' !== $tooltip_text ),
				'text'    => $tooltip_text,
			),
			'badge'               => array(
				'enabled' => ! empty( $launcher['badge'] ),
				'text'    => isset( $launcher['badgeText'] ) ? (string) $launcher['badgeText'] : '',
			),
			'attention_animation' => array(
				'enabled'       => ( isset( $launcher['animation'] ) && 'none' !== $launcher['animation'] ),
				'delay_seconds' => 5,
			),
			'icon_svg'            => $icon_svg,
			'icon_type'           => $is_custom_icon ? 'image' : 'preset',
			'icon_image_url'      => $icon_image_url,
			'mobile'              => array(
				'enabled'  => ! $mobile_hide,
				'hide'     => $mobile_hide,
				'size'     => $mobile_size_px,
				'position' => $mobile_position,
			),
			// 'user_state_icons' intentionally absent — chat-log-dependent
			// feature, deleted (design doc §4.3 row / §9 N5).
		),
		'cta'         => array( 'contact_url' => $contact_url ),
		// Parent-page launcher strings that widget-loader.js renders itself.
		// The framed document's own dictionary (window.__NS_I18N__, §4.4) is
		// unreachable from the parent page — the loader is a classic script
		// outside the iframe — so the same-shaped strings are shipped here and
		// read via that file's own `li18n( key, fallback )` helper.
		'i18n'        => array(
			'cta_label'           => __( 'Contact', 'neccospeak' ),
			'aria_open'           => __( 'Open AI agent', 'neccospeak' ),
			'toggle_label'        => __( 'Ask AI', 'neccospeak' ),
			'aria_frame'          => __( 'AI agent', 'neccospeak' ),
			'unavailable_title'   => __( 'Chat is temporarily unavailable', 'neccospeak' ),
			'unavailable_tooltip' => __( 'Chat is temporarily unavailable.', 'neccospeak' ),
			'retry_label'         => __( 'Retry', 'neccospeak' ),
		),
		'framePath'   => $frame_path,
		'iframeAllow' => 'microphone; clipboard-write',
		'preload'     => 'click',
	);
}

/*
-------------------------------------------------------------------------
 * 4.4 — window.__NS_I18N__.
 * ----------------------------------------------------------------------
 */

/**
 * Builds `window.__NS_I18N__` (design doc §4.4): the JS-side i18n dictionary
 * for every locale-dependent string the framed JS produces itself rather than
 * receiving from this document (R-I18N-2 — read via `t(key, fallback)`,
 * design doc §4.4's own `ns/ns-rest.js` `t()` helper). Two kinds live here:
 *
 *   1. DOM-reaching UI copy (`openai-realtime-agent/widget-app-persistent.js`,
 *      `widget/text-chat-controller.js`, `openai-realtime-connector.js`,
 *      `response-error-classifier.js`, `reconnect-config.js`).
 *   2. LLM-facing prompt fragments — greeting instructions, page-context
 *      lines, tool-result messages (`prompt/greeting-time-hint.js`,
 *      `widget/navigation-context.js`, `tool/tool-handler.js`). Never
 *      rendered, but they must follow the visitor's language exactly like
 *      the UI does, so they are keyed the same way (0.3.0 / INV-12: the JS
 *      source carries the English msgid as its fallback and nothing else).
 *
 * This key set is reconciled against that JS's actual `t(key, …)` call
 * sites (`grep -rhoE '\bt\(\s*"[a-z0-9_]+"' assets/widget-frame/js/`) — every
 * key `t()` reads exists here, and every key here is read by `t()`. Keep the
 * two in sync: `tests/test-frame-php.php` machine-checks that this array's
 * key set is a superset of the JS source's `t()` key set.
 *
 * Placeholder convention: unlike WP's usual `%s`/`%d` (`printf`-style), the
 * JS caller does `t(key, fallback).replace("{n}", …)` etc. — i.e. it
 * performs a literal substring replace on whatever `t()` returns (dict value
 * or fallback), not a `sprintf()`. So values here must keep the literal
 * `{n}` / `{reason}` / `{msg}` token verbatim for the replace to find it;
 * translators may move the token but must not translate it.
 *
 * @return array<string,string>
 */
function neccospeak_frame_i18n() {
	return array(
		// Copy-message button / aria labels.
		'aria_copied'                          => __( 'Copied', 'neccospeak' ),
		'aria_copy_message'                    => __( 'Copy message', 'neccospeak' ),
		'copy_title'                           => __( 'Copy', 'neccospeak' ),
		// Common error-bubble action links (renderErrorBubble()).
		'btn_retry'                            => __( 'Try again', 'neccospeak' ),
		'btn_contact_form'                     => __( 'Go to contact form', 'neccospeak' ),
		'btn_call'                             => __( 'Call', 'neccospeak' ),
		// Mic / speaker mute button titles (btnMicMute/btnSpeakerMute).
		'btn_mic_muted_title'                  => __( 'Microphone muted', 'neccospeak' ),
		'btn_mic_on_title'                     => __( 'Microphone on', 'neccospeak' ),
		'btn_speaker_muted_title'              => __( 'Speaker muted', 'neccospeak' ),
		'btn_speaker_on_title'                 => __( 'Speaker on', 'neccospeak' ),
		// Status text (#status-text) states.
		'status_text_mode'                     => __( 'Text mode', 'neccospeak' ),
		'status_voice_connecting'              => __( 'Connecting voice', 'neccospeak' ),
		'status_connecting'                    => __( 'Connecting...', 'neccospeak' ),
		'status_disconnected'                  => __( 'Disconnected', 'neccospeak' ),
		// HTTP-status-mapped chat errors (text-chat-controller.js).
		'err_rate_limited'                     => __( 'We are experiencing high traffic. Please wait a moment and try again.', 'neccospeak' ),
		'err_service_unavailable'              => __( 'The service is currently unavailable. Please wait a moment and try again.', 'neccospeak' ),
		'err_forbidden'                        => __( 'The request was rejected. Please reload the page and try again.', 'neccospeak' ),
		'err_connect_failed'                   => __( 'A connection problem occurred. Please wait a moment and try again.', 'neccospeak' ),
		/* translators: the JS fallback embeds the actual timeout in seconds; the translated string cannot reflect that dynamic value (pre-existing JS behavior — see widget/text-chat-controller.js's err_idle_timeout call site). */
		'err_idle_timeout'                     => __( 'No response arrived for a while. Please try again.', 'neccospeak' ),
		// Stream / response-failure error bubble.
		'err_stream_title'                     => __( 'A connection problem occurred', 'neccospeak' ),
		'err_stream_body'                      => __( 'The message could not be sent. Please wait a moment and try again.', 'neccospeak' ),
		'err_response_failed_title'            => __( 'Response failed', 'neccospeak' ),
		'err_response_failed_body'             => __( 'Failed to generate a response. Please wait a moment and try again.', 'neccospeak' ),
		/* translators: {msg} is a literal token the JS replaces with the underlying error message — keep it verbatim, do not translate it. */
		'err_voice_mode'                       => __( 'Voice mode error: {msg}', 'neccospeak' ),
		// System-bubble messages (addMessage(…, 'system')).
		/* translators: {n} is a literal token the JS replaces with the reconnect attempt number — keep it verbatim, do not translate it. */
		'msg_reconnect_success'                => __( 'Reconnected (recovered on attempt {n})', 'neccospeak' ),
		/* translators: {reason} is a literal token the JS replaces with the reason text — keep it verbatim, do not translate it. */
		'msg_switched_to_text_reason'          => __( '⚠️ {reason}Switched to text mode.', 'neccospeak' ),
		'msg_voice_blocked_other_tab'          => __( 'A voice session is active in another tab. Please close that tab and try again.', 'neccospeak' ),
		'msg_switching_to_voice'               => __( 'Switching to voice mode...', 'neccospeak' ),
		'msg_switched_to_text'                 => __( 'Returned to text mode.', 'neccospeak' ),
		'msg_voice_connected'                  => __( 'Connected. Please start talking.', 'neccospeak' ),
		'msg_ios_speaker_muted'                => __( '🔇 The speaker is muted on iOS. Tap the speaker button to hear audio.', 'neccospeak' ),
		'msg_mic_switch_failed'                => __( 'Failed to switch microphone.', 'neccospeak' ),
		/* translators: {label} is a literal token the JS replaces with the microphone's device name — keep it verbatim, do not translate it. */
		'msg_mic_switched'                     => __( 'Switched the microphone to "{label}".', 'neccospeak' ),
		'msg_mic_disconnected'                 => __( 'The microphone was disconnected. Select a microphone from the mic menu.', 'neccospeak' ),
		'msg_prompt_fallback_warning'          => __( 'There is a problem loading the settings. Response quality may differ from normal.', 'neccospeak' ),
		// Mic device popover.
		'mic_not_found'                        => __( 'No microphone found', 'neccospeak' ),
		'mic_system_default'                   => __( 'System default', 'neccospeak' ),
		/* translators: {n} is a literal token the JS replaces with the 1-based device index — keep it verbatim, do not translate it. */
		'mic_label_fallback'                   => __( 'Microphone {n}', 'neccospeak' ),
		'mic_device_name_default'              => __( 'Microphone', 'neccospeak' ),
		// Loading indicator default status.
		'loading_default_status'               => __( 'Preparing an answer...', 'neccospeak' ),
		// Contact-form prefill result banners (handleAction()'s 'prefill' case).
		/* translators: {n} is a literal token the JS replaces with the number of prefilled fields — keep it verbatim, do not translate it. */
		'msg_contact_prefilled'                => __( "Prefilled {n} fields in the contact form.\nPlease review the content and tap the send button (sending is manual).", 'neccospeak' ),
		'msg_contact_navigated'                => __( 'Moved to the contact form. Please review the content and send it.', 'neccospeak' ),
		// Auto-reconnect (Issue #193 / G-013) status + failure UI.
		/* translators: {attempt} and {max} are literal tokens the JS replaces with the current and maximum attempt numbers — keep them verbatim, do not translate them. */
		'status_reconnecting'                  => __( 'Reconnecting ({attempt}/{max})...', 'neccospeak' ),
		'err_reconnect_other_tab'              => __( 'Reconnection was canceled because a voice session is active in another tab', 'neccospeak' ),
		'msg_auto_reconnect_failed'            => __( 'Automatic reconnection failed.', 'neccospeak' ),
		'btn_reconnect'                        => __( 'Reconnect', 'neccospeak' ),
		// Microphone-denial reasons (handleMicDeniedFallback(), fed into msg_switched_to_text_reason's {reason}).
		'err_mic_permission_denied'            => __( 'Microphone access is not allowed.', 'neccospeak' ),
		'err_mic_not_found'                    => __( 'Microphone not found.', 'neccospeak' ),
		'err_mic_in_use'                       => __( 'The microphone is in use by another app.', 'neccospeak' ),
		'err_mic_constraints'                  => __( 'A microphone configuration error occurred.', 'neccospeak' ),
		'err_mic_failed'                       => __( 'Failed to access the microphone.', 'neccospeak' ),
		'msg_mic_retry_prompt'                 => __( 'Allow microphone access, then', 'neccospeak' ),
		'btn_retry_voice_mode'                 => __( 'Retry voice mode', 'neccospeak' ),
		// dev.61: tab-visibility Stage2 reason (_startBackgroundSuspendTimer()),
		// fed into msg_switched_to_text_reason's {reason} — same slot as the
		// microphone-denial reasons above. Trailing space is deliberate:
		// msg_switched_to_text_reason's "{reason}Switched to text mode." has no
		// space of its own between {reason} and the next word (an existing
		// rough edge the mic-denial reasons above also have, unfixed, out of
		// scope here) — this new reason's English source absorbs it with a
		// trailing space instead of touching the shared template string.
		'reason_tab_hidden'                    => __( 'This tab moved to the background, so the voice session was ended. ', 'neccospeak' ),
		// Voice-connection failures (handleConnectionFailure() / connectWithTimeout()).
		'err_session_config_failed'            => __( 'Failed to load session settings. Please try again.', 'neccospeak' ),
		'err_connect_failed_retry'             => __( 'Connection failed. Please try again.', 'neccospeak' ),
		'err_connect_timeout'                  => __( 'The connection timed out. Please check your network and try again.', 'neccospeak' ),
		/* translators: {msg} is a literal token the JS replaces with the underlying error message — keep it verbatim, do not translate it. */
		'err_connection_generic'               => __( 'Connection error: {msg}', 'neccospeak' ),
		/* translators: {ms} is a literal token the JS replaces with the timeout in milliseconds — keep it verbatim, do not translate it. */
		'err_connect_timeout_internal'         => __( 'Connection timed out ({ms}ms)', 'neccospeak' ),
		// WebRTC / transport errors raised inside openai-realtime-connector.js.
		'err_token_failed'                     => __( 'Failed to obtain the authentication token', 'neccospeak' ),
		'err_webrtc_failed'                    => __( 'The WebRTC connection failed. Please reconnect.', 'neccospeak' ),
		'err_connection_unstable'              => __( 'The connection is unstable. Please reconnect.', 'neccospeak' ),
		'err_datachannel_closed'               => __( 'The data channel was disconnected. Please reconnect.', 'neccospeak' ),
		'err_media_devices_unavailable'        => __( 'mediaDevices is not available (insecure context or missing iframe permission)', 'neccospeak' ),
		/* translators: {status} is a literal token the JS replaces with the HTTP status code — keep it verbatim, do not translate it. */
		'err_sdp_send'                         => __( 'SDP send error: {status}', 'neccospeak' ),
		'err_unknown'                          => __( 'An unknown error occurred', 'neccospeak' ),
		// Realtime API response.failed categories (response-error-classifier.js).
		'err_ai_user_error'                    => __( '⚠️ There is a configuration problem. Please contact the administrator.', 'neccospeak' ),
		'err_ai_rate_limit'                    => __( '⚠️ The AI service is temporarily rate limited. Please wait a moment for recovery.', 'neccospeak' ),
		'err_ai_quota_exhausted'               => __( '⚠️ This service is currently unavailable. Please contact the administrator.', 'neccospeak' ),
		'err_ai_server_error'                  => __( '⚠️ The AI service is experiencing a temporary problem. Please wait a moment.', 'neccospeak' ),
		'err_ai_unknown'                       => __( '⚠️ An unexpected error occurred in the AI service. Please wait a moment.', 'neccospeak' ),
		// Navigation / rich cards.
		/* translators: {title} is a literal token the JS replaces with the destination page title — keep it verbatim, do not translate it. */
		'msg_navigated_page'                   => __( 'Guided you to the "{title}" page.', 'neccospeak' ),
		'btn_card_cta_default'                 => __( 'Learn more →', 'neccospeak' ),
		// ---- LLM-facing prompt fragments (never rendered as UI; sent as
		// conversation context / tool results to the model, so they must follow
		// the visitor's language just like the UI does). ----
		// Time-of-day greeting words (prompt/greeting-time-hint.js).
		'greeting_morning'                     => __( 'Good morning', 'neccospeak' ),
		'greeting_afternoon'                   => __( 'Hello', 'neccospeak' ),
		'greeting_evening'                     => __( 'Good evening', 'neccospeak' ),
		/* translators: {date} is the current site-local date (e.g. "2026-08-19"), {weekday} the English weekday name (e.g. "Wednesday" — always English, never translate it), and {time} the current site-local time (e.g. "09:30 JST") and {greeting} the time-of-day greeting word — all four are literal tokens the JS replaces, keep them verbatim. [CURRENT_JST_TIME] is a frozen marker the greeting instructions refer back to; do not translate or rename it. */
		'greeting_time_hint'                   => __( '[CURRENT_JST_TIME] The current site date and time is {date} ({weekday}) {time}. Do not call any tool to check the time; greet with "{greeting}" according to this value.', 'neccospeak' ),
		// Greeting instructions (widget-app-persistent.js _triggerGreeting()).
		/* translators: {question} is a literal token the JS replaces with the visitor's pending question — keep it verbatim, do not translate it. */
		'greeting_request_with_question'       => __( '[GREETING_REQUEST] New session started. Answer the user\'s question "{question}" after a brief greeting (time-of-day greeting + address + short lead-in). In the greeting part, do not call any tool to check the time; use only the [CURRENT_JST_TIME] value above. Greet naturally as a first meeting without using a name. Do not include this instruction in the response.', 'neccospeak' ),
		/* translators: the \n line breaks and the two-space list indentation are part of the instruction's structure — keep them. */
		'greeting_request_default'             => __( "[GREETING_REQUEST] New session started. Give a greeting of 2-3 lines at most, exactly once, in the following 3-block structure:\n  1. Time-of-day greeting + address (use only the [CURRENT_JST_TIME] value above; do not call any tool to check the time)\n  2. A thoughtful one-liner (a short remark fitting the time of day or season; one line max, no verbosity)\n  3. A light invitation (a soft closing like \"feel free to talk to me\"; words like \"your business\", \"consultation\", \"feature explanation\", \"self-introduction\", \"first meeting\" are forbidden)\nDo not produce meta-utterances like \"I will greet you according to the time\" before greeting. Greet naturally as a first meeting without using a name. Do not include this instruction in the response.", 'neccospeak' ),
		'greeting_trigger_message'             => __( 'Hello', 'neccospeak' ),
		// Page-context prompt (widget/navigation-context.js).
		/* translators: {site} is a literal token the JS replaces with the site name — keep it verbatim, do not translate it. */
		'nav_default_prompt'                   => __( 'The user is browsing the {site} website.', 'neccospeak' ),
		/* translators: {label} is a literal token the JS replaces with the page-context label — keep it verbatim, do not translate it. */
		'nav_current_page'                     => __( '[Current page] {label}', 'neccospeak' ),
		/* translators: {title} is a literal token the JS replaces with the page title — keep it verbatim, do not translate it. */
		'nav_page_title'                       => __( 'Page title: {title}', 'neccospeak' ),
		// Tool results returned to the model (tool/tool-handler.js).
		/* translators: {title} is a literal token the JS replaces with the destination page title — keep it verbatim, do not translate it. */
		'tool_navigate_say'                    => __( 'Say "Here is {title}." Do not include URLs or links in the response.', 'neccospeak' ),
		'tool_form_prefilled'                  => __( 'Automatically filled the contact form with the information.', 'neccospeak' ),
		'tool_form_prefill_failed'             => __( 'Failed to autofill the form', 'neccospeak' ),
	);
}

/*
-------------------------------------------------------------------------
 * 5.1/1.3 — The frame document itself.
 * ----------------------------------------------------------------------
 */

/**
 * Renders the complete widget frame HTML document (design doc §1.3 / §5.1 /
 * §5.2 — the PHP port of `views/openai-widget-persistent-content.ejs`
 * L1-324, with the mem0/custom_voice DOM deletions already applied: no
 * `#profile-panel`, no `#btn-profile`, no `window.__SESSION_LOG_API_KEY__`
 * injection). DOM element order/class names/id names/attributes are
 * otherwise unchanged from the EJS source (INV-T2) — every user-visible
 * string is escaped at its point of output (R-I18N-1).
 *
 * @param array  $opts  neccospeak_get_options().
 * @param string $nonce The per-request CSP/script nonce (design doc §2.1
 *                       step 4 / §2.3) — echoed into exactly 3
 *                       `<script nonce="…">` tags (P7).
 * @return string A complete `<!DOCTYPE html>…</html>` document.
 */
function neccospeak_frame_html( $opts, $nonce ) {
	$opts = is_array( $opts ) ? $opts : array();
	$s    = function ( $key, $default ) use ( $opts ) {
		return ( array_key_exists( $key, $opts ) && is_scalar( $opts[ $key ] ) ) ? (string) $opts[ $key ] : (string) $default;
	};
	$b    = function ( $key, $default ) use ( $opts ) {
		return array_key_exists( $key, $opts ) ? (bool) $opts[ $key ] : (bool) $default;
	};

	$base    = defined( 'NECCOSPEAK_URL' ) ? NECCOSPEAK_URL : '';
	$version = defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '';
	$ver_qs  = '' !== $version ? '?ver=' . rawurlencode( $version ) : '';

	$header_title = $s( 'widget_title', __( 'AI Assistant', 'neccospeak' ) );

	$header_logo_url    = function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'header_logo_id', 'medium' ) : '';

	$style_body = neccospeak_frame_primary_css( $opts ) . neccospeak_frame_theme_css( $opts );
	$custom_css = function_exists( 'neccospeak_widget_custom_css_escape' ) ? neccospeak_widget_custom_css_escape( $s( 'custom_css', '' ) ) : '';

	// Welcome view.
	$home_icon          = $s( 'home_icon', 'none' );
	$welcome_avatar_url = function_exists( 'neccospeak_widget_attachment_url' ) ? neccospeak_widget_attachment_url( $opts, 'home_avatar_id', 'thumbnail' ) : '';
	$welcome_icon_bi    = neccospeak_frame_welcome_icon_bi( $home_icon );
	$welcome_headline   = $s( 'home_headline', '' );
	$welcome_body       = $s( 'home_body', '' );
	if ( '' === $welcome_body ) {
		$welcome_body = $s( 'welcome_message', '' );
	}
	$capability_tags = function_exists( 'neccospeak_widget_home_capability_tags' ) ? neccospeak_widget_home_capability_tags( $opts ) : array();

	// 0.3.0-dev.37 (§3): same single source as __AGENT_CONFIG__.quick_actions
	// (neccospeak_frame_chips() — cta_list message rows, legacy
	// home_capabilities fallback). Icons come back already mapped to
	// bootstrap-icons suffixes, so no per-chip re-mapping happens here anymore.
	$chips = neccospeak_frame_chips( $opts );

	$contact_url   = function_exists( 'neccospeak_widget_safe_url' ) ? neccospeak_widget_safe_url( $s( 'fallback_contact_url', '' ) ) : '';
	$contact_phone = function_exists( 'neccospeak_widget_safe_phone' ) ? neccospeak_widget_safe_phone( $s( 'fallback_contact_phone', '' ) ) : '';

	$input_placeholder = $s( 'home_input_placeholder', '' );
	if ( '' === $input_placeholder ) {
		$input_placeholder = __( 'Type your question', 'neccospeak' );
	}

	$show_voice_button = $b( 'voice_enabled', false ) && $b( 'show_voice_button', true );

	// window.__* injections.
	$client_config   = neccospeak_frame_client_config( $opts );
	$scheme          = function_exists( 'wp_parse_url' ) ? (string) wp_parse_url( function_exists( 'home_url' ) ? home_url( '/' ) : '', PHP_URL_SCHEME ) : 'https';
	$host            = function_exists( 'wp_parse_url' ) ? (string) wp_parse_url( function_exists( 'home_url' ) ? home_url( '/' ) : '', PHP_URL_HOST ) : '';
	$allowed_origins = array( ( '' !== $scheme ? $scheme : 'https' ) . '://' . $host );
	$rest_url        = function_exists( 'rest_url' ) ? esc_url_raw( rest_url( 'neccospeak/v1/' ) ) : '';

	ob_start();
	?>
<!DOCTYPE html>
<html lang="<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><?php echo esc_html( $header_title ); ?></title>

<link rel="preconnect" href="https://api.openai.com">
<link rel="dns-prefetch" href="https://api.openai.com">

<!-- Bootstrap Icons — bundled locally (design doc D5 / §1.3), no external CDN. -->
<link rel="stylesheet" href="<?php echo esc_url( $base . 'assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css' . $ver_qs ); ?>"><?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- this <link> is part of a raw HTML document this function returns as a template_redirect early-exit HTTP response body (design doc §2.1), not part of a normal WP page render; wp_enqueue_style()/wp_head() never run for this response at all, so there is no enqueue mechanism to hook into here. ?>

<link rel="stylesheet" href="<?php echo esc_url( $base . 'assets/widget-frame/widget-frame.css' . $ver_qs ); ?>"><?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- see the identical note on the bootstrap-icons <link> immediately above. ?>

<style>
:root{<?php echo $style_body; /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- every declaration is built from R-THEME-2-validated (hex-regex / int-clamp / fixed-table) values only, never raw admin free text (see neccospeak_frame_theme_css()/neccospeak_frame_primary_css()). */ ?>}
</style>
	<?php if ( '' !== $custom_css ) : ?>
<style id="agent-custom-css"><?php echo $custom_css; /* phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already passed through neccospeak_widget_custom_css_escape() (strips all tags + any literal '<'), the same existing defense-in-depth gate the rest of the plugin uses for this exact option (P11). */ ?></style>
	<?php endif; ?>
</head>
<body>
<div class="agent" id="agent-root">
	<!-- Header -->
	<div class="agent__header">
		<div class="agent__header-info">
			<?php if ( '' !== $header_logo_url ) : ?>
			<img
				class="agent__header-logo"
				src="<?php echo esc_url( $header_logo_url ); ?>"
				alt="<?php echo esc_attr( $header_title ); ?>"
				height="24"
			>
			<?php endif; ?>
			<div class="agent__header-title"><?php echo esc_html( $header_title ); ?></div>
			<?php
			// ステータス表示は常時表示（home_status_indicator は参照しない）。
			// この設定行は管理画面の「NeccoSpeak独自の設定」隠しセクション内にあり
			// 導入者が UI から切り替えられないため、値に関わらず一律表示とする
			// （0.3.0-dev.44、ユーザー指示）。#status-dot/#status-text は JS
			// (widget-app-persistent.js) が接続状態表示に参照する必須要素。
			?>
			<div class="agent__header-status">
				<span class="agent__status-dot" id="status-dot"></span>
				<span id="status-text"><?php echo esc_html__( 'Online', 'neccospeak' ); ?></span>
			</div>
		</div>
		<div class="agent__header-actions">
			<div class="agent__view-tabs" id="view-tabs" role="tablist" aria-label="<?php echo esc_attr__( 'View switcher', 'neccospeak' ); ?>">
				<button class="agent__view-tab agent__view-tab--active" id="tab-home" role="tab" aria-selected="true" aria-controls="home-view" title="<?php echo esc_attr__( 'Home', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Home screen', 'neccospeak' ); ?>"><i class="bi bi-house-door"></i></button>
				<button class="agent__view-tab" id="tab-chat" role="tab" aria-selected="false" aria-controls="chat-body" title="<?php echo esc_attr__( 'Chat', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Chat screen', 'neccospeak' ); ?>"><i class="bi bi-chat-text"></i></button>
			</div>
			<div class="agent__conversation-menu" id="conversation-menu">
				<button
					class="agent__header-btn"
					id="btn-conversation-menu"
					title="<?php echo esc_attr__( 'Conversation menu', 'neccospeak' ); ?>"
					aria-label="<?php echo esc_attr__( 'Open conversation menu', 'neccospeak' ); ?>"
					aria-haspopup="menu"
					aria-expanded="false"
					aria-controls="conversation-menu-dropdown"
				><i class="bi bi-three-dots-vertical"></i></button>
				<div
					class="agent__menu-dropdown agent--hidden"
					id="conversation-menu-dropdown"
					role="menu"
					aria-labelledby="btn-conversation-menu"
				>
					<button class="agent__menu-item" id="menu-item-new-conversation" role="menuitem" type="button">
						<i class="bi bi-arrow-clockwise"></i>
						<span><?php echo esc_html__( 'Start a new conversation', 'neccospeak' ); ?></span>
					</button>
				</div>
			</div>
			<button class="agent__header-btn" id="btn-minimize" title="<?php echo esc_attr__( 'Close', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Close widget', 'neccospeak' ); ?>"><i class="bi bi-chevron-down"></i></button>
		</div>
	</div>

	<!-- Home View -->
	<div class="agent__home-view" id="home-view">
		<div class="agent__welcome" id="welcome-screen">
			<div class="agent__welcome-icon">
			<?php
			if ( '' !== $welcome_avatar_url ) :
				?>
				<img src="<?php echo esc_url( $welcome_avatar_url ); ?>" alt="" class="agent__welcome-icon-img">
				<?php
else :
	?>
				<i class="bi bi-<?php echo esc_attr( $welcome_icon_bi ); ?>"></i><?php endif; ?></div>
			<div class="agent__welcome-title"><?php echo ( '' !== $welcome_headline ) ? esc_html( $welcome_headline ) : esc_html__( 'Hello!', 'neccospeak' ); ?></div>
			<div class="agent__welcome-message"><?php echo ( '' !== $welcome_body ) ? esc_html( $welcome_body ) : esc_html__( 'I can help with our services and how to get started. Feel free to ask anything.', 'neccospeak' ); ?></div>
			<!-- The production EJS's first-visit privacy notice (EJS L147-152) is
			     deliberately NOT ported anymore: it asserted consent to a privacy
			     policy it could not link to (no per-site URL exists), which is
			     worse than saying nothing (removed 0.3.0-dev.26, product decision). -->
			<?php if ( ! empty( $capability_tags ) ) : ?>
			<ul class="agent__welcome-capabilities">
				<?php foreach ( $capability_tags as $cap ) : ?>
				<li class="agent__welcome-capability"><?php echo esc_html( $cap ); ?></li>
				<?php endforeach; ?>
			</ul>
			<?php endif; ?>
		</div>
		<?php
		/**
		 * Quick-action initial render (EJS L161-185, unchanged rule): always
		 * rendered inside `agent--hidden` — client JS (updateQuickActions())
		 * decides when to reveal it, after any page-navigation-driven
		 * page-specific override has had a chance to replace it first.
		 */
		?>
		<div class="agent__quick-actions agent--hidden" id="quick-actions">
			<?php foreach ( $chips as $chip ) : ?>
				<?php // data-action is the text SENT on click (dev.37: a message row's own value); the button's visible text is its label. ?>
				<button class="agent__quick-btn" data-action="<?php echo esc_attr( isset( $chip['action'] ) ? $chip['action'] : '' ); ?>">
					<?php
					if ( ! empty( $chip['icon'] ) ) :
						?>
						<span class="agent__quick-icon"><i class="bi bi-<?php echo esc_attr( $chip['icon'] ); ?>"></i></span><?php endif; ?>
					<span><?php echo esc_html( isset( $chip['label'] ) ? $chip['label'] : '' ); ?></span>
				</button>
			<?php endforeach; ?>
		</div>
		<div class="agent__cta-panel" id="cta-panel"></div>
		<?php if ( '' !== $contact_url ) : ?>
		<div class="agent__human-fallback">
			<?php echo esc_html__( 'If the AI can\'t resolve your issue,', 'neccospeak' ); ?> <a href="<?php echo esc_url( $contact_url ); ?>" target="_blank" rel="noopener" class="agent__human-fallback-link"><?php echo esc_html__( 'contact a staff member', 'neccospeak' ); ?></a>
			<?php
			if ( '' !== $contact_phone ) :
				?>
				/ <a href="tel:<?php echo esc_attr( $contact_phone ); ?>" class="agent__human-fallback-link"><?php echo esc_html__( 'Phone:', 'neccospeak' ); ?> <?php echo esc_html( $contact_phone ); ?></a><?php endif; ?>
		</div>
		<?php endif; ?>
	</div>

	<!-- Messages Body -->
	<div class="agent__body agent--hidden" id="chat-body" role="log" aria-live="polite" aria-label="<?php echo esc_attr__( 'Chat messages', 'neccospeak' ); ?>">
	</div>

	<!-- Waveform bar (realtime mode) -->
	<div class="agent__waveform-bar agent--hidden" id="waveform-bar">
		<div class="agent__connection-indicator agent--hidden" id="connection-indicator">
			<div class="agent__connection-spinner"></div>
			<span><?php echo esc_html__( 'Connecting voice...', 'neccospeak' ); ?></span>
		</div>
		<canvas id="waveform-canvas" width="200" height="32"></canvas>
	</div>

	<!-- Realtime controls -->
	<div class="agent__realtime-controls agent--hidden" id="realtime-controls">
		<div class="agent__mic-group">
			<button class="agent__mute-btn" id="btn-mic-mute" title="<?php echo esc_attr__( 'Microphone on', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Toggle microphone mute', 'neccospeak' ); ?>" aria-pressed="false"><i class="bi bi-mic-fill"></i></button>
			<button class="agent__mic-select-btn agent--hidden" id="btn-mic-select" title="<?php echo esc_attr__( 'Select microphone', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Select microphone device', 'neccospeak' ); ?>" aria-expanded="false" aria-haspopup="true"><i class="bi bi-caret-down-fill"></i></button>
			<div class="agent__mic-popover agent--hidden" id="mic-device-popover" aria-label="<?php echo esc_attr__( 'Microphone device list', 'neccospeak' ); ?>">
				<div class="agent__mic-popover-header">
					<span class="agent__mic-popover-title"><?php echo esc_html__( 'Select microphone', 'neccospeak' ); ?></span>
					<button class="agent__mic-refresh-btn" id="btn-mic-refresh" title="<?php echo esc_attr__( 'Refresh device list', 'neccospeak' ); ?>"><i class="bi bi-arrow-clockwise"></i></button>
				</div>
				<ul class="agent__mic-device-list" id="mic-device-list"></ul>
			</div>
		</div>
		<span class="agent__mic-device-name" id="mic-device-name"><?php echo esc_html__( 'Microphone', 'neccospeak' ); ?></span>
		<button class="agent__mute-btn" id="btn-speaker-mute" title="<?php echo esc_attr__( 'Speaker on', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Toggle speaker mute', 'neccospeak' ); ?>" aria-pressed="false"><i class="bi bi-volume-up-fill"></i></button>
		<button class="agent__realtime-back" id="btn-back-to-text"><?php echo esc_html__( 'Back to text', 'neccospeak' ); ?></button>
	</div>

	<!-- Footer (Input area) -->
	<div class="agent__footer">
		<div class="agent__footer-input-row">
			<textarea
				class="agent__input"
				id="chat-input"
				placeholder="<?php echo esc_attr( $input_placeholder ); ?>"
				autocomplete="off"
				aria-label="<?php echo esc_attr__( 'Message input', 'neccospeak' ); ?>"
				rows="1"
				inputmode="text"
			></textarea>
			<button class="agent__send-btn" id="btn-send" title="<?php echo esc_attr__( 'Send', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Send message', 'neccospeak' ); ?>" disabled><i class="bi bi-send-fill"></i></button>
		</div>
		<div class="agent__footer-bottom">
			<?php if ( $b( 'disclaimer_inline_enabled', true ) ) : ?>
			<div class="agent__footer-disclaimer"><?php echo ( '' !== $s( 'disclaimer_inline_text', '' ) ) ? esc_html( $s( 'disclaimer_inline_text', '' ) ) : esc_html__( 'AI answers are for reference only. Please check with a staff member before making important decisions.', 'neccospeak' ); ?></div>
			<?php endif; ?>
			<div class="agent__footer-actions">
				<?php /* #btn-profile deleted (design doc §5.1 — mem0/profile panel) */ ?>
				<?php if ( $show_voice_button ) : ?>
				<button class="agent__footer-btn" id="btn-voice" title="<?php echo esc_attr__( 'Talk by voice', 'neccospeak' ); ?>" aria-label="<?php echo esc_attr__( 'Switch to voice mode', 'neccospeak' ); ?>"><i class="bi bi-mic-fill"></i></button>
				<?php endif; ?>
			</div>
		</div>
	</div>
	<?php /* #profile-panel overlay deleted in full (design doc §5.1) */ ?>
</div>

	<?php
	/**
	 * Every `window.__*` value below is encoded with `JSON_HEX_TAG` — the native
	 * PHP equivalent of design doc §4.2's `.replace(/</g, '<')` requirement,
	 * escaping both `<`/`>` as part of encoding itself so a value containing the
	 * literal substring `</script` can never prematurely close the tag —
	 * `JSON_UNESCAPED_UNICODE` keeps the Japanese text readable in view-source.
	 * `wp_json_encode()` is called directly at each echo site (never through a
	 * same-file wrapper) so WPCS's `WordPress.Security.EscapeOutput` sniff
	 * recognizes it as escaped with no `phpcs:ignore` needed anywhere in this
	 * function (design doc §6.3's own completion condition — unlike the
	 * SSE-output exception elsewhere in this codebase).
	 */
	?>
<!-- Widget config injection (server → client) -->
<script nonce="<?php echo esc_attr( $nonce ); ?>">
window.__AGENT_CONFIG__ = <?php echo wp_json_encode( $client_config, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); ?>;
window.__ALLOWED_ORIGINS__ = <?php echo wp_json_encode( $allowed_origins, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); ?>;
</script>

<!-- Platform / i18n injection -->
<script nonce="<?php echo esc_attr( $nonce ); ?>">
window.__NS_REST__ = <?php echo wp_json_encode( $rest_url, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); ?>;
window.__NS_VERSION__ = <?php echo wp_json_encode( $version, JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); ?>;
window.__NS_I18N__ = <?php echo wp_json_encode( neccospeak_frame_i18n(), JSON_HEX_TAG | JSON_UNESCAPED_UNICODE ); ?>;
</script>

<?php
// 0.3.0-dev.64: version-bust the whole module graph. MUST be emitted before
// any modulepreload/module script — an import map is only honored if it is
// parsed before the first module load. See
// neccospeak_frame_module_import_map()'s docblock for the stale-mixed-graph
// incident this prevents.
$ns_import_map = neccospeak_frame_module_import_map( $base, $version );
if ( ! empty( $ns_import_map ) ) :
	?>
<!-- Module graph cache busting (dev.64) -->
<script type="importmap" nonce="<?php echo esc_attr( $nonce ); ?>">
<?php echo wp_json_encode( array( 'imports' => $ns_import_map ), JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); ?>
</script>
<?php endif; ?>

<!-- Main application module -->
<link rel="modulepreload" href="<?php echo esc_url( $base . 'assets/widget-frame/js/widget/text-chat-controller.js' . $ver_qs ); ?>">
<link rel="modulepreload" href="<?php echo esc_url( $base . 'assets/widget-frame/js/widget/markdown-lite.js' . $ver_qs ); ?>">
<link rel="modulepreload" href="<?php echo esc_url( $base . 'assets/widget-frame/js/openai-realtime-agent/widget-app-persistent.js' . $ver_qs ); ?>">

<script nonce="<?php echo esc_attr( $nonce ); ?>" type="module">
import { initPersistentWidget } from "<?php echo esc_url_raw( $base . 'assets/widget-frame/js/openai-realtime-agent/widget-app-persistent.js' . $ver_qs ); ?>";

function boot() {
	initPersistentWidget();
}

if ( document.readyState === "loading" ) {
	document.addEventListener( "DOMContentLoaded", () => boot() );
} else {
	boot();
}

const chatInput = document.getElementById( "chat-input" );
const btnSend = document.getElementById( "btn-send" );
if ( chatInput && btnSend ) {
	chatInput.addEventListener( "input", () => {
		btnSend.disabled = chatInput.value.trim().length === 0;
	} );
}
</script>
</body>
</html>
	<?php
	return (string) ob_get_clean();
}
