<?php
/**
 * NeccoSpeak — Front-end asset wiring: the `window.neccoSpeak` config object
 * and the list of optional feature-module <script> handles (design rev.2
 * §2 / §3).
 *
 * Owner: impl-split. Explicitly excluded from includes/* 's impl-php
 * ownership (design rev.2 §1).
 *
 * Every accessor here tolerates a completely empty (or partially-populated)
 * $opts array: neccospeak_widget_config()/neccospeak_widget_modules() must
 * keep working with hardcoded fallbacks even before impl-php's option keys
 * (design rev.2 §7) land, so the two agents can start in parallel (§12).
 *
 * @package NeccoSpeak
 */

defined( 'ABSPATH' ) || exit;

/**
 * Scheme allow-list for outbound links rendered by the widget.
 *
 * The canonical implementation lives in includes/settings.php (impl-php,
 * neccospeak_safe_url()). This inline fallback keeps
 * includes/frontend-config.php independently runnable while impl-php's work
 * is still in flight, and keeps tests/test-frontend-config.php green
 * regardless of landing order (design rev.2 §3.4 / §7.1 / §12.1).
 *
 * @param string $raw Candidate URL.
 * @return string Safe URL, or '' when rejected.
 */
function neccospeak_widget_safe_url( $raw ) {
	if ( function_exists( 'neccospeak_safe_url' ) ) {
		return neccospeak_safe_url( $raw );
	}
	$url = trim( (string) $raw );
	if ( '' === $url ) {
		return '';
	}
	if ( preg_match( '/[\x00-\x1F\x7F\s]/', $url ) ) {
		return '';
	}
	$lower = strtolower( $url );
	if ( 0 !== strpos( $lower, 'https://' ) && 0 !== strpos( $lower, 'http://' ) && 0 !== strpos( $lower, 'mailto:' ) ) {
		return '';
	}
	$safe = esc_url_raw( $url );
	return is_string( $safe ) ? $safe : '';
}

/**
 * Multibyte-safe character clamp (design phase4 §2.7 / INV-13). Used for
 * config-time (not save-time) clamping of launcher_badge_text and
 * home_capabilities lines — substr() is never used here: cutting a
 * multibyte string (e.g. Japanese) at a raw byte offset can produce invalid
 * UTF-8, which makes wp_json_encode() return false further up the call
 * chain (neccospeak.php's wp_localize_script()) and silently breaks the
 * ENTIRE widget boot, not just the clamped field.
 *
 * When mb_substr() is unavailable (no mbstring extension), the string is
 * returned uncut rather than risking a byte-level cut — a longer badge is a
 * cosmetic issue; an unbootable widget is not.
 *
 * @param string $s         Input string.
 * @param int    $max_chars Maximum character count.
 * @return string
 */
function neccospeak_widget_mb_clamp( $s, $max_chars ) {
	$s = (string) $s;
	if ( function_exists( 'mb_substr' ) ) {
		return mb_substr( $s, 0, (int) $max_chars, 'UTF-8' );
	}
	return $s;
}

/**
 * Resolve an attachment ID option to a safe, absolute image URL (design
 * phase4 §2.7 full contract). Used for theme_watermark_id / launcher_icon_id
 * / home_icon_id / home_avatar_id — all four go through this single
 * function so the scheme allow-list + esc_url_raw() defense is applied
 * uniformly.
 *
 * Always '' when: media functions are unavailable (test environment / a
 * front end running before impl-php's schema lands), the option is unset or
 * not a positive int, the attachment no longer exists, or the resolved URL
 * fails the https/http/mailto scheme allow-list (protocol-relative and
 * relative URLs — which some CDN-offload / image-optimization plugins'
 * wp_get_attachment_image_src filters return — are deliberately rejected
 * here rather than silently breaking later inside widget-core.js's own
 * `image` CSS-variable validator, design phase4 §3.4).
 *
 * @param array  $opts neccospeak_get_options() (or any subset/superset array).
 * @param string $key  Option key holding the attachment ID.
 * @param string $size Registered image size to request.
 * @return string Safe absolute URL, or ''.
 */
function neccospeak_widget_attachment_url( $opts, $key, $size ) {
	if ( ! function_exists( 'wp_get_attachment_image_url' ) ) {
		return '';
	}
	$id = ( is_array( $opts ) && isset( $opts[ $key ] ) && is_scalar( $opts[ $key ] ) ) ? (int) $opts[ $key ] : 0;
	if ( $id < 1 ) {
		return '';
	}
	$url = wp_get_attachment_image_url( $id, $size );
	if ( ! is_string( $url ) || '' === $url ) {
		return '';
	}
	return neccospeak_widget_safe_url( $url );
}

/**
 * G23 theme -> CSS-variable map (design phase4 §2.7/§3/§4). Returns ONLY the
 * variables whose backing option differs from its zero-emission default
 * (INV-1: with an untouched install, this is array()), so a default-config
 * mount never gains a single `style.setProperty()` call it didn't have
 * before Phase 4.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<string,string> CSS custom-property name => value.
 */
function neccospeak_widget_theme( $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;
	};

	$theme = array();

	// Direct color passthroughs: option already holds a '#rrggbb'-shaped
	// value (or '' = unset) courtesy of the 'color_optional' sanitize type.
	$color_map = array(
		'theme_panel_bg'          => '--neccospeak-panel-bg',
		'theme_panel_text'        => '--neccospeak-panel-text',
		'theme_panel_muted'       => '--neccospeak-panel-muted',
		'theme_panel_border'      => '--neccospeak-panel-border',
		'theme_thread_bg'         => '--neccospeak-thread-bg',
		'theme_header_text'       => '--neccospeak-header-text',
		'theme_bubble_bot_bg'     => '--neccospeak-bubble-bot-bg',
		'theme_bubble_bot_text'   => '--neccospeak-bubble-bot-text',
		'theme_bubble_bot_border' => '--neccospeak-bubble-bot-border',
		'theme_bubble_user_bg'    => '--neccospeak-bubble-user-bg',
		'theme_bubble_user_text'  => '--neccospeak-bubble-user-text',
	);
	foreach ( $color_map as $opt_key => $var_name ) {
		$v = $s( $opt_key, '' );
		if ( '' !== $v ) {
			$theme[ $var_name ] = $v;
		}
	}

	// Header background: solid color, or a 2-stop linear-gradient() when
	// theme_header_style === 'gradient' AND both stops are set (design
	// phase4 §4.3). A gradient with only one stop set 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 && '' !== $header_c1 && '' !== $header_c2 ) {
		$angle = max( 0, min( 360, $i( 'theme_header_angle', 135 ) ) );
		$theme['--neccospeak-header-bg'] = sprintf( 'linear-gradient(%ddeg, %s 0%%, %s 100%%)', $angle, $header_c1, $header_c2 );
	} elseif ( '' !== $header_c1 ) {
		$theme['--neccospeak-header-bg'] = $header_c1;
	}

	// Radii (px). Only emitted when they differ from the widget.css literal.
	// 0.2.0-dev.7: the literals below are the full-version UI's own
	// defaults, kept in sync with neccospeak_default_options() and
	// assets/widget.css's own --neccospeak-*-radius declarations -- an
	// untouched install still emits zero radius overrides (INV-1 holds
	// against the new baseline).
	// docs/ui-source-diff.md §採用方針 V6/V9 (0.2.0-dev.13): bubble_radius
	// 14 -> 16 and button_radius 21 -> 8 -- see the matching comment in
	// includes/settings.php's neccospeak_default_options() for why.
	$panel_radius = max( 0, min( 32, $i( 'theme_panel_radius', 16 ) ) );
	if ( 16 !== $panel_radius ) {
		$theme['--neccospeak-panel-radius'] = $panel_radius . 'px';
	}
	$bubble_radius = max( 0, min( 24, $i( 'theme_bubble_radius', 16 ) ) );
	if ( 16 !== $bubble_radius ) {
		$theme['--neccospeak-bubble-radius'] = $bubble_radius . 'px';
	}
	$button_radius = max( 0, min( 24, $i( 'theme_button_radius', 8 ) ) );
	if ( 8 !== $button_radius ) {
		$theme['--neccospeak-button-radius'] = $button_radius . 'px';
	}

	// Font family: fixed PHP-side lookup table (design phase4 §3.3) — the
	// enum value is never interpolated into CSS directly. 'system' is the
	// zero-emission default (widget.css's own stack), so it is never emitted.
	$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 ] ) ) {
		$theme['--neccospeak-font-family'] = $font_family_map[ $font_family ];
	}

	// Font size: 1 base + 2 derived variables (design phase4 §3.3). String
	// conversion uses number_format() rather than (string)$float so the
	// output is locale-independent — a de_DE-locale server calling
	// setlocale(LC_NUMERIC, ...) elsewhere must never turn "14.5px" into the
	// CSS-invalid "14,5px" (which core's `length` validator would then
	// silently discard, §3.4).
	$font_size = max( 12, min( 20, $i( 'theme_font_size', 15 ) ) );
	if ( 15 !== $font_size ) {
		$theme['--neccospeak-font-size']         = $font_size . 'px';
		$theme['--neccospeak-bubble-font-size']  = number_format( $font_size - 0.5, 1, '.', '' ) . 'px';
		$theme['--neccospeak-control-font-size'] = number_format( $font_size - 1, 0, '.', '' ) . 'px';
	}

	// Background watermark image (design phase4 §4.4). Both an attachment
	// AND a non-zero opacity are required, or nothing is emitted at all
	// (no point requesting an image nobody will ever see).
	$wm_id = $i( 'theme_watermark_id', 0 );
	$wm_op = max( 0, min( 30, $i( 'theme_watermark_opacity', 6 ) ) );
	if ( $wm_id > 0 && $wm_op > 0 ) {
		$wm_url = neccospeak_widget_attachment_url( $opts, 'theme_watermark_id', 'large' );
		// Belt-and-braces: the CSS-variable `image` validator on the core
		// side (widget-core.js §3.4) rejects a url("...") containing these
		// characters anyway, but rejecting HERE means a bad URL degrades to
		// "no watermark" rather than "the whole --neccospeak-watermark-image
		// declaration silently vanishes and CSS parsing continues past it".
		if ( '' !== $wm_url && ! preg_match( '/["\'()<>\\\\\s]/', $wm_url ) ) {
			$theme['--neccospeak-watermark-image']   = 'url("' . $wm_url . '")';
			$theme['--neccospeak-watermark-opacity'] = number_format( $wm_op / 100, 2, '.', '' );
		}
	}

	return $theme;
}

/**
 * G24 launcher config (design phase4 §5.1). Unlike neccospeak_widget_theme(),
 * this ALWAYS returns every documented key (core re-validates every value
 * again on its own side, design phase4 §5.2 multi-layer defense) — a missing
 * key would force every consumer to write its own fallback instead of core
 * owning the single canonical one.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array
 */
function neccospeak_widget_launcher( $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;
	};

	$icon = $s( 'launcher_icon', 'chat' );
	if ( ! in_array( $icon, array( 'chat', 'bubbles', 'help', 'sparkle', 'headset', 'mic', 'custom' ), true ) ) {
		$icon = 'chat';
	}
	$icon_url = ( 'custom' === $icon ) ? neccospeak_widget_attachment_url( $opts, 'launcher_icon_id', 'thumbnail' ) : '';

	$size = $s( 'launcher_size', 'm' );
	if ( ! in_array( $size, array( 's', 'm', 'l' ), true ) ) {
		$size = 'm';
	}

	$position = ( 'bottom-left' === $s( 'launcher_position', 'bottom-right' ) ) ? 'bottom-left' : 'bottom-right';

	$shape = ( 'rounded' === $s( 'launcher_shape', 'circle' ) ) ? 'rounded' : 'circle';

	$animation = $s( 'launcher_animation', 'none' );
	if ( ! in_array( $animation, array( 'none', 'pulse', 'bounce' ), true ) ) {
		$animation = 'none';
	}

	$mobile_size = $s( 'launcher_mobile_size', 'inherit' );
	if ( ! in_array( $mobile_size, array( 'inherit', 's', 'm', 'l' ), true ) ) {
		$mobile_size = 'inherit';
	}
	$mobile_position = $s( 'launcher_mobile_position', 'inherit' );
	if ( ! in_array( $mobile_position, array( 'inherit', 'bottom-right', 'bottom-left' ), true ) ) {
		$mobile_position = 'inherit';
	}

	return array(
		'icon'          => $icon,
		'iconUrl'       => $icon_url,
		'size'          => $size,
		'position'      => $position,
		'offsetX'       => max( 0, min( 200, $i( 'launcher_offset_x', 20 ) ) ),
		'offsetY'       => max( 0, min( 200, $i( 'launcher_offset_y', 20 ) ) ),
		'shape'         => $shape,
		'badge'         => $b( 'launcher_badge', false ),
		// mb_substr-clamped here (config-build time), NOT at option-save
		// time — INV-13, design phase4 §2.7: the JS side never re-clamps.
		'badgeText'     => neccospeak_widget_mb_clamp( $s( 'launcher_badge_text', '' ), 2 ),
		'tooltip'       => $s( 'launcher_tooltip', '' ),
		'animation'     => $animation,
		'animationOnce' => $b( 'launcher_animation_once', true ),
		// Phase 7d (docs/phase7d-plan.md §2 C4, item 13): pill label. This
		// function's own contract ("always emit every key", the comment at
		// this function's call site in neccospeak_widget_config()) means
		// both keys are always present, even at their INV-1-breaking true
		// default — assets/js/widget-core.js's build() reads them
		// unconditionally, same as every other launcher.* key.
		'showLabel'     => $b( 'launcher_label_enabled', true ),
		'label'         => $s( 'launcher_label_text', '' ),
		'mobile'        => array(
			'hide'     => $b( 'launcher_mobile_hide', false ),
			'size'     => $mobile_size,
			'position' => $mobile_position,
			'offsetX'  => max( 0, min( 200, $i( 'launcher_mobile_offset_x', 16 ) ) ),
			'offsetY'  => max( 0, min( 200, $i( 'launcher_mobile_offset_y', 16 ) ) ),
		),
	);
}

/**
 * G25 home "suggested questions" chips (design phase4 §2.7): home_capabilities
 * is a newline-separated textarea, clamped here (not at save time) to at
 * most 6 non-empty lines of at most 60 characters each (INV-13, mb_substr).
 *
 * Phase 7b (docs/phase7-ui-trace.md §7b.8.1): each line may optionally start
 * with "iconName|" to pick an icon tile for that chip. No new option was
 * added for this — it is a backward-compatible extension of the existing
 * home_capabilities row grammar: a line with no recognized icon prefix (every
 * pre-Phase-7b config, and any line whose text just happens to contain "|")
 * renders exactly as it did before, only now as `array('icon'=>'', ...)`
 * instead of a bare string.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<int,array{icon:string,label:string}>
 */
function neccospeak_widget_home_chips( $opts ) {
	$raw = ( is_array( $opts ) && isset( $opts['home_capabilities'] ) && is_scalar( $opts['home_capabilities'] ) )
		? (string) $opts['home_capabilities']
		: '';
	if ( '' === trim( $raw ) ) {
		return array();
	}
	$lines = preg_split( '/\r\n|\r|\n/', $raw );
	if ( ! is_array( $lines ) ) {
		return array();
	}
	// Mirrors ICON_ALLOWED (assets/js/widget-cta.js) / CHIP_ICONS
	// (assets/js/widget-home.js) — every name here must exist in
	// widget-core.js's ICON_PATHS, since it is rendered via ctx.svg().
	$icons = array( 'chat', 'help', 'sparkle', 'doc', 'calendar', 'cart', 'mail', 'phone', 'link', 'arrowRight', 'home', 'mic', 'briefcase', 'building', 'chart' );
	$out   = array();
	foreach ( $lines as $line ) {
		$line = trim( $line );
		if ( '' === $line ) {
			continue;
		}
		$icon = '';
		if ( false !== strpos( $line, '|' ) ) {
			// explode(…, 2) so a label may itself contain '|'. Splitting on a
			// single-byte ASCII delimiter is UTF-8 safe (self-synchronizing),
			// unlike the raw substr() this file's mb_clamp docblock warns
			// against — no multibyte character can ever contain a literal
			// ASCII '|' byte, so this never cuts one in half.
			$parts = explode( '|', $line, 2 );
			$head  = trim( $parts[0] );
			// Only a full-string, allow-listed match is treated as an icon
			// name (this doubles as the "looks like an identifier" check —
			// every allow-listed name is ASCII a-zA-Z, so an accidental match
			// against arbitrary label text is not a realistic concern).
			if ( in_array( $head, $icons, true ) ) {
				$icon = $head;
				$line = trim( $parts[1] );
			}
		}
		if ( '' === $line ) {
			continue;
		}
		$out[] = array(
			'icon'  => $icon,
			'label' => neccospeak_widget_mb_clamp( $line, 60 ),
		);
		if ( count( $out ) >= 6 ) {
			break;
		}
	}
	return $out;
}

/**
 * Phase 7d (docs/phase7d-plan.md §3.1/§4 B-2, item 3): non-clickable
 * "capability" tags shown on the Home view — deliberately a SEPARATE option
 * (home_capability_tags) from home_capabilities (neccospeak_widget_home_chips()
 * above), which stays exactly as it was: clickable "suggested question"
 * chips. Same 1-line-per-item / max 6 lines / 60-char clamp convention as
 * that function, but WITHOUT its "icon|label" prefix grammar — the full-
 * version UI source this mirrors (css:1715-1734/EJS:153-159) renders these as
 * plain text tags with no icon slot at all.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string[] Up to 6 labels, each at most 60 characters.
 */
function neccospeak_widget_home_capability_tags( $opts ) {
	$raw = ( is_array( $opts ) && isset( $opts['home_capability_tags'] ) && is_scalar( $opts['home_capability_tags'] ) )
		? (string) $opts['home_capability_tags']
		: '';
	if ( '' === trim( $raw ) ) {
		return array();
	}
	$lines = preg_split( '/\r\n|\r|\n/', $raw );
	if ( ! is_array( $lines ) ) {
		return array();
	}
	$out = array();
	foreach ( $lines as $line ) {
		$line = trim( $line );
		if ( '' === $line ) {
			continue;
		}
		$out[] = neccospeak_widget_mb_clamp( $line, 60 );
		if ( count( $out ) >= 6 ) {
			break;
		}
	}
	return $out;
}

/**
 * Phase 7d (docs/phase7d-plan.md §4 B-3, item 4): the human-fallback phone
 * number (fallback_contact_phone) reduced to digits/+/-/()/space ONLY — the
 * one config-build-time scheme fix this item needs (design's own note:
 * "tel: スキーム固定...数値/記号のみ許可を frontend-config.php 側で行い、JS
 * には整形済み文字列だけ渡す"). assets/js/widget-home.js builds the final
 * `tel:` href from this already-cleaned string; it never sees the raw option
 * value.
 *
 * @param string $raw Raw fallback_contact_phone option value.
 * @return string
 */
function neccospeak_widget_safe_phone( $raw ) {
	$s = is_scalar( $raw ) ? (string) $raw : '';
	$s = preg_replace( '/[^0-9+\-() ]/', '', $s );
	return is_string( $s ) ? trim( $s ) : '';
}

/**
 * G14 CTA items in the JS-consumed shape (design phase4 §2.7): re-keys the
 * stored `replies` field to `after` and passes every other field through
 * as-is — the stored shape (neccospeak_sanitize_cta_list(), includes/settings.php)
 * is already fully validated, so this is a pure re-shape, not a second
 * validation pass. Returns array() whenever cta_enabled is off, so the
 * config and neccospeak_widget_modules()'s 'neccospeak-cta' enqueue flag can
 * never disagree about whether CTA is active.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array
 */
function neccospeak_widget_cta_items( $opts ) {
	$opts = is_array( $opts ) ? $opts : array();
	if ( empty( $opts['cta_enabled'] ) ) {
		return array();
	}
	$raw = ( isset( $opts['cta_list'] ) && is_array( $opts['cta_list'] ) ) ? $opts['cta_list'] : array();
	$out = array();
	foreach ( $raw as $row ) {
		if ( ! is_array( $row ) || ! isset( $row['label'], $row['icon'], $row['type'], $row['value'], $row['cond'], $row['delay'], $row['replies'] ) ) {
			continue; // Defensive: never trust the stored shape blindly (same discipline as includes/admin-cta.php).
		}
		$item = array(
			'label' => (string) $row['label'],
			'icon'  => (string) $row['icon'],
			'type'  => (string) $row['type'],
			'value' => (string) $row['value'],
			'cond'  => (string) $row['cond'],
			'delay' => (int) $row['delay'],
			'after' => (int) $row['replies'],
		);
		// Phase 6 (design phase6 §8.2/§8.6): 'map'/'seed' only ever appear on
		// 'cf7_prefill' rows — every pre-Phase-6 type stays exactly 7 keys
		// (INV-1: the config shape for an all-default install, where
		// cf7_prefill can never be saved without CF7 active, is unchanged).
		if ( 'cf7_prefill' === $item['type'] ) {
			$map = ( isset( $row['map'] ) && is_array( $row['map'] ) ) ? $row['map'] : array();
			$item['map'] = array(
				'name'    => isset( $map['name'] ) ? (string) $map['name'] : '',
				'email'   => isset( $map['email'] ) ? (string) $map['email'] : '',
				'message' => isset( $map['message'] ) ? (string) $map['message'] : '',
			);
			/**
			 * Filters the seed values offered for the CF7 prefill hand-off
			 * (design phase6 §8.6, developer filter). Lets a site inject
			 * fixed values (e.g. the current logged-in user's own name/email)
			 * without the plugin itself ever handling personal data — the
			 * client (assets/js/widget-cf7.js) prefers a non-empty seed field
			 * over the conversation-derived value, and only fills the
			 * remaining empty fields from the chat (§8.4).
			 *
			 * @param array $data { @type string $name @type string $email @type string $message }
			 * @param array $item The CTA item (label/icon/type/value/cond/delay/after/map).
			 * @param array $opts neccospeak_get_options().
			 */
			$seed = apply_filters(
				'neccospeak_cf7_prefill_data',
				array(
					'name'    => '',
					'email'   => '',
					'message' => '',
				),
				$item,
				$opts
			);
			$item['seed'] = array(
				'name'    => neccospeak_widget_mb_clamp( is_array( $seed ) && isset( $seed['name'] ) ? (string) $seed['name'] : '', 100 ),
				'email'   => neccospeak_widget_mb_clamp( is_array( $seed ) && isset( $seed['email'] ) ? (string) $seed['email'] : '', 200 ),
				'message' => neccospeak_widget_mb_clamp( is_array( $seed ) && isset( $seed['message'] ) ? (string) $seed['message'] : '', 2000 ),
			);
		}
		$out[] = $item;
	}
	return $out;
}

/**
 * Whether at least one saved (and currently activatable) CTA row is a
 * 'cf7_prefill' action (design phase6 §2.5/§10.1). Drives both
 * features.cf7.enabled and the independent CF7-receiver enqueue (§8.4a) —
 * neccospeak_widget_cta_items() is the single source of truth both read, so
 * they can never disagree about whether the feature is active.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return bool
 */
function neccospeak_widget_has_cf7_cta( $opts ) {
	foreach ( neccospeak_widget_cta_items( $opts ) as $item ) {
		if ( isset( $item['type'] ) && 'cf7_prefill' === $item['type'] ) {
			return true;
		}
	}
	return false;
}

/**
 * G37 GA/GTM event allow-list, in canonical order (design phase4 §2.7):
 * iterates the ALLOWED list (not the stored one) so the result is
 * order-stable regardless of how ga_events was saved.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string[]
 */
function neccospeak_widget_ga_events( $opts ) {
	$allowed = array( 'open', 'close', 'message_sent', 'message_received', 'voice_start', 'voice_end', 'cta_click' );
	$raw     = ( is_array( $opts ) && isset( $opts['ga_events'] ) && is_array( $opts['ga_events'] ) ) ? $opts['ga_events'] : $allowed;
	$out     = array();
	foreach ( $allowed as $ev ) {
		if ( in_array( $ev, $raw, true ) ) {
			$out[] = $ev;
		}
	}
	return $out;
}

/**
 * Enabled G31 analytics event names (design phase5 §10.5), consumed by
 * assets/js/widget-analytics.js. Same allowed set and canonical-order
 * normalization pattern as neccospeak_widget_ga_events() above -- kept as a
 * separate function (rather than reused) because the two options
 * ('ga_events' vs 'analytics_events') are independent settings that can
 * diverge, even though today's allow-list happens to be identical.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string[]
 */
function neccospeak_widget_analytics_events( $opts ) {
	$allowed = array( 'open', 'close', 'message_sent', 'message_received', 'voice_start', 'voice_end', 'cta_click' );
	$raw     = ( is_array( $opts ) && isset( $opts['analytics_events'] ) && is_array( $opts['analytics_events'] ) ) ? $opts['analytics_events'] : $allowed;
	$out     = array();
	foreach ( $allowed as $ev ) {
		if ( in_array( $ev, $raw, true ) ) {
			$out[] = $ev;
		}
	}
	return $out;
}

/**
 * Build the `window.neccoSpeak` config object passed to
 * wp_localize_script() on the core script handle (design rev.2 §3 / §3.4).
 *
 * Every option read uses array_key_exists() against $opts (not
 * neccospeak_option(), which requires includes/settings.php to be loaded)
 * so this function has zero hard dependency on impl-php's option schema
 * having landed yet — an absent/incomplete $opts always degrades to the
 * documented hardcoded default, never a PHP notice.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array
 */
function neccospeak_widget_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 ) ? (int) $opts[ $key ] : (int) $default;
	};
	$s = function ( $key, $default ) use ( $opts ) {
		return array_key_exists( $key, $opts ) ? (string) $opts[ $key ] : (string) $default;
	};

	// Phase 6 (design phase6 §4.7, G15): per-page context. $page_id is 0
	// unless BOTH page_context_enabled is on AND the current front-end
	// request resolves to a validated, viewable post (impl-context's own
	// §4.2 gate) — includes/page-context.php may not have landed yet, so
	// every call here is function_exists()-guarded (D9) and degrades to the
	// pre-Phase-6 value (0 / the unmodified 'welcome_message' option).
	$page_id = ( $b( 'page_context_enabled', false ) && function_exists( 'neccospeak_page_context_current_id' ) )
		? neccospeak_page_context_current_id()
		: 0;

	$welcome = $s( 'welcome_message', '' );
	if ( $page_id && function_exists( 'neccospeak_page_context_welcome' ) ) {
		$page_welcome = neccospeak_page_context_welcome( $page_id );
		if ( '' !== $page_welcome ) {
			$welcome = $page_welcome; // Per-page override (§4.7).
		}
	}

	// Phase 7d (docs/phase7d-plan.md §4 B-3, item 4): human-fallback phone
	// number, alongside the existing fallback_contact_url/_label. Reuses the
	// SAME url as features.resilience.contactUrl above (a single
	// fallback_contact_url option, two consumers) — the 'contact' key itself
	// is only emitted when that url is non-empty (INV-1): a site with no
	// contact link configured gets no new DOM either.
	$home_contact_url = neccospeak_widget_safe_url( $s( 'fallback_contact_url', '' ) );
	$home_contact      = ( '' !== $home_contact_url )
		? array(
			'url'   => $home_contact_url,
			'label' => $s( 'fallback_contact_label', '' ),
			'phone' => neccospeak_widget_safe_phone( $s( 'fallback_contact_phone', '' ) ),
		)
		: null;

	return array(
		'restUrl'       => esc_url_raw( rest_url( 'neccospeak/v1/' ) ),
		'version'       => defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '',
		'title'         => $s( 'widget_title', __( 'AI Assistant', 'neccospeak' ) ),
		'welcome'       => $welcome,
		// Phase 6 (design phase6 §10.1): new top-level key. 0 under every
		// default (page_context_enabled off) — INV-1.
		'pageId'        => $page_id,
		'color'         => $s( 'brand_color', '#2f6fed' ),
		'voiceEnabled'  => $b( 'voice_enabled', false ),
		'voice'         => $s( 'voice', 'alloy' ),
		'realtimeModel' => $s( 'realtime_model', '' ),
		// Phase 4 (design phase4 §2.7): top-level theme/launcher. 'theme' is
		// empty array() under an untouched install (INV-1: neither
		// neccospeak_widget_theme() nor anything downstream emits a single
		// CSS variable in that case); 'launcher' always carries every key
		// (impl-core re-validates each one again on its own side).
		'theme'         => neccospeak_widget_theme( $opts ),
		'launcher'      => neccospeak_widget_launcher( $opts ),
		// Phase 7d (docs/phase7d-plan.md §2 C2, item 2/16): new top-level key,
		// sibling of 'title'. 'launcher' above already carries "always emit
		// every key" (impl-core re-validates on its own side); this one
		// follows the same convention rather than INV-1's "omit when default"
		// one — widget-core.js's build() (impl-core, §4 A-2/A-11) reads both
		// keys unconditionally.
		'header'        => array(
			'logoUrl'         => neccospeak_widget_attachment_url( $opts, 'header_logo_id', 'medium' ),
			'statusIndicator' => $b( 'home_status_indicator', true ),
		),
		'features'      => array(
			'voice'      => array( 'showButton' => $b( 'show_voice_button', true ) ),
			'markdown'   => array( 'enabled' => $b( 'markdown_enabled', true ) ),
			'session'    => array(
				'enabled'     => $b( 'session_restore_enabled', true ),
				'ttlMinutes'  => $i( 'session_ttl_minutes', 30 ),
				'maxMessages' => $i( 'session_max_messages', 30 ),
				'maxBytes'    => $i( 'session_max_bytes', 60000 ),
			),
			// Phase 7d (docs/phase7d-plan.md §2 C3, item 5): loading-indicator
			// style. 'lines' is fixed at 3 for now (no option of its own yet —
			// matches the full-version UI's own fixed 3-line skeleton).
			'loading'    => array(
				'style' => ( 'skeleton' === $s( 'loading_style', 'dots' ) ) ? 'skeleton' : 'dots',
				'lines' => 3,
			),
			'disclaimer' => array(
				'inlineEnabled' => $b( 'disclaimer_inline_enabled', true ),
				'inlineText'    => $s( 'disclaimer_inline_text', '' ),
				'modalEnabled'  => $b( 'disclaimer_modal_enabled', false ),
				'modalText'     => $s( 'disclaimer_modal_text', '' ),
				'ackScope'      => ( 'session' === $s( 'disclaimer_ack_scope', 'local' ) ) ? 'session' : 'local',
				// Phase 7d (docs/phase7d-plan.md §4 B-4, item 6): non-modal
				// first-visit Home notice, alternative to the modal above.
			),
			'voiceNotice' => array(
				'enabled' => $b( 'voice_notice_enabled', true ),
				'text'    => $s( 'voice_notice_text', '' ),
			),
			'resilience' => array(
				// Client timeout must outlive the server's worst case:
				// 60s per upstream call x 2 (G17 retry) + PHP overhead. See
				// design rev.2 §0.3 D3.
				'timeoutMs'    => max( 10000, min( 240000, $i( 'chat_timeout_seconds', 130 ) * 1000 ) ),
				'contactUrl'   => neccospeak_widget_safe_url( $s( 'fallback_contact_url', '' ) ),
				'contactLabel' => $s( 'fallback_contact_label', '' ),
			),
			'home'       => array(
				// 0.2.0-dev.7: fallback flipped to true, mirroring
				// neccospeak_default_options()'s new home_enabled/
				// home_status_indicator defaults (product decision to ship the
				// full-version UI out of the box).
				'enabled'         => $b( 'home_enabled', true ),
				'defaultView'     => ( 'chat' === $s( 'home_default_view', 'home' ) ) ? 'chat' : 'home',
				'headline'        => $s( 'home_headline', '' ),
				'body'            => $s( 'home_body', '' ),
				'newChatEnabled'  => $b( 'home_new_chat_enabled', true ),
				'statusIndicator' => $b( 'home_status_indicator', true ),
				// Phase 4 (design phase4 §2.7/§6.5): G25 home view details.
				'icon'            => $s( 'home_icon', 'none' ),
				'iconUrl'         => neccospeak_widget_attachment_url( $opts, 'home_icon_id', 'medium' ),
				'avatarUrl'       => neccospeak_widget_attachment_url( $opts, 'home_avatar_id', 'thumbnail' ),
				'chips'           => neccospeak_widget_home_chips( $opts ),
				'placeholder'     => $s( 'home_input_placeholder', '' ),
				// Phase 7d (docs/phase7d-plan.md §4 B-2/B-3, items 3/4): the
				// non-clickable capability tags, and the human-fallback contact
				// block (both new). 'contact' is omitted entirely (never a null
				// or empty-string value) when no contact URL is configured —
				// INV-1, mirrors features.resilience.contactUrl's own '' fallback
				// but as key presence rather than an empty string, since
				// assets/js/widget-home.js's own render gate is "does this key
				// exist at all", matching the full-version UI's `cfg.contact.url`
				// DOM condition (docs/phase7d-plan.md §4 B-3).
				'capabilities'    => neccospeak_widget_home_capability_tags( $opts ),
			) + ( $home_contact ? array( 'contact' => $home_contact ) : array() ),
			// Phase 4 (design phase4 §2.7/§6.1): G14 CTA.
			'cta'        => array(
				'enabled' => $b( 'cta_enabled', false ),
				'home'    => $b( 'cta_home_enabled', true ),
				'chat'    => $b( 'cta_chat_enabled', true ),
				'heading' => $s( 'cta_heading', '' ),
				'items'   => neccospeak_widget_cta_items( $opts ),
			),
			// Phase 4 (design phase4 §2.7/§9.1): G37 GA/GTM dataLayer integration.
			'ga'         => array(
				'enabled' => $b( 'ga_enabled', false ),
				'events'  => neccospeak_widget_ga_events( $opts ),
			),
			// Phase 4 (design phase4 §2.7/§7.2): G42 in-call voice UI. 'enabled'
			// requires BOTH voice_enabled (voice itself) and voice_ui_enabled
			// (the UI add-on) — a UI for a feature that is off makes no sense.
			'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 ),
			),
			// G20 (b): client-side PII warning. Consumed by assets/js/widget-pii.js.
			//
			// Deliberately NO 'banned' subtree here (design rev.2 §9, review
			// C9): G21 (blocked-word filter) is entirely server-side and no JS
			// module reads a features.banned config, so publishing it would be
			// dead config that also tells every visitor "this site has a
			// blocklist" for no functional reason.
			'pii'        => array(
				'warn'  => $b( 'pii_client_warning', true ),
				'types' => neccospeak_widget_pii_types( $opts ),
				'text'  => $s( 'pii_warning_text', '' ),
			),
			// Phase 5 (design phase5 §10.5): G31 analytics.
			// Deliberately mirrors 'ga' above rather than folding into it --
			// this is the plugin's own self-hosted event log (writes only to
			// the plugin's own DB tables, never window.dataLayer), a distinct
			// concept from the optional GTM/GA dataLayer bridge.
			//
			// 2026-08 撤去記録: ここにあった 'feedback' サブツリー (G19) は
			// AI応答評価機能ごと削除した。
			// 2026-08 撤去記録: ここにあった 'chatMeta' キー (G41 会話ログとの
			// sid/turn 相関用フラグ) は、会話ログ機能ごと削除した。JS 側の消費者は
			// 元々ゼロだった(assets/widget-frame/js/widget/text-chat-controller.js
			// は sid/turn をこの設定と無関係に無条件送信していた — 判断事項Cで
			// その送信自体も削除済み)。
			'analytics'  => array(
				'enabled'     => $b( 'analytics_enabled', false ),
				'events'      => neccospeak_widget_analytics_events( $opts ),
				'usageReport' => $b( 'usage_tracking_enabled', true )
					&& $b( 'usage_voice_report_enabled', true )
					&& $b( 'voice_enabled', false ),
			),
			// Phase 6 (design phase6 §10.1): G29 progressive rendering.
			// 'mode' is impl-chat-owned (includes/rest.php's
			// neccospeak_stream_mode(), §7.1/S14/D9) — it applies the
			// SSE-support and tools-enabled downgrades server-side, so the
			// front end never has to re-derive them. function_exists()
			// guarded per D9; the ': off' fallback below only fires when
			// includes/rest.php itself hasn't loaded (never true in
			// production — neccospeak_get_options() always merges
			// neccospeak_default_options(), so $opts['stream_mode'] is never
			// actually absent). 0.2.0-dev.7: the real product default is now
			// 'typewriter' (neccospeak_default_options()), NOT 'off' — this
			// fallback is deliberately left at the old, more conservative
			// value rather than tracking that change, same as
			// neccospeak_stream_mode()'s own internal fallback.
			'stream'     => array(
				'mode'  => function_exists( 'neccospeak_stream_mode' ) ? neccospeak_stream_mode( $opts ) : 'off',
				'speed' => $s( 'stream_speed', 'normal' ),
				'skip'  => $b( 'stream_skip_enabled', true ),
			),
			// Phase 6 (design phase6 §10.1): G38 Contact Form 7 prefill.
			'cf7'        => array(
				'enabled' => $b( 'cf7_prefill_enabled', false )
					&& neccospeak_cf7_active()
					&& neccospeak_widget_has_cf7_cta( $opts ),
				'ttlMs'   => defined( 'NECCOSPEAK_CF7_HANDOFF_TTL_MS' ) ? NECCOSPEAK_CF7_HANDOFF_TTL_MS : 300000,
			),
		),
		// impl-php owns every translatable string (includes/settings.php,
		// neccospeak_widget_i18n()). The guard keeps impl-split runnable
		// before that lands; every JS module carries English fallbacks, so
		// an empty array is never fatal (design rev.2 §10 / §12.1).
		'i18n'          => function_exists( 'neccospeak_widget_i18n' ) ? neccospeak_widget_i18n() : array(),
	);
}

/**
 * Enabled PII categories for the CLIENT-SIDE warning (design rev.2 §9).
 *
 * A local re-implementation rather than a call to includes/rest.php's
 * neccospeak_pii_types() — includes/frontend-config.php is meant to stay
 * independently runnable even before impl-restfront's option schema has
 * landed (same "no hard dependency on another agent's file" policy as
 * neccospeak_widget_safe_url() above), and rest.php is never required from
 * here (design rev.2 §11.4 dependency graph).
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return string[] Subset of array('email','phone','card'), canonical order.
 */
function neccospeak_widget_pii_types( $opts ) {
	$allowed = array( 'email', 'phone', 'card' );
	$raw     = ( is_array( $opts ) && isset( $opts['pii_types'] ) && is_array( $opts['pii_types'] ) )
		? $opts['pii_types']
		: $allowed; // Absent option -> the documented default (all three enabled).
	$out = array();
	foreach ( $allowed as $t ) {
		if ( in_array( $t, $raw, true ) ) {
			$out[] = $t;
		}
	}
	return $out;
}

/**
 * Front-end output side of the G23 'Additional CSS' box (design phase4
 * §4.5). Pure string transform — separated out from
 * neccospeak_widget_custom_css() below purely so it is directly unit
 * testable without needing wp_style_is()/wp_add_inline_style() shims.
 *
 * This is a SECOND line of defense on top of neccospeak_sanitize_custom_css()
 * (includes/settings.php, the save-time gate): the option row can be
 * modified through paths that never call that sanitizer at all — WP-CLI
 * `wp option patch`, a raw DB edit/migration, or some other plugin's
 * update_option() call — so a value containing "</style><script>…" must
 * never reach an inline <style> tag verbatim just because it skipped the
 * save-time gate. A value that DID go through the save-time gate is
 * unchanged by this function (idempotent no-op), since that gate already
 * guarantees no '<' survives.
 *
 * @param string $css Raw stored custom_css value.
 * @return string Escaped CSS, or '' when there is nothing left to output.
 */
function neccospeak_widget_custom_css_escape( $css ) {
	$css = (string) $css;
	if ( '' === trim( $css ) ) {
		return '';
	}
	$css = wp_strip_all_tags( $css );
	$css = str_replace( '<', '', $css );
	return ( '' === trim( $css ) ) ? '' : $css;
}

/**
 * Outputs the G23 'Additional CSS' box as an inline style attached to the
 * 'neccospeak' stylesheet handle (design phase4 §4.5). Hooked at priority 20
 * (neccospeak.php's own neccospeak_enqueue() runs at the default priority 10,
 * design phase4 §4.5 D1 — this file owns its own hook rather than requiring
 * an edit to neccospeak.php, which impl-admin's file-ownership table
 * restricts to a version-string-only change).
 *
 * A no-op whenever the 'neccospeak' stylesheet was never enqueued in the
 * first place (neccospeak_should_display() returned false) — wp_add_inline_style()
 * on a handle that was never registered/enqueued this request is silently
 * dropped by WP core anyway, but checking explicitly avoids doing the
 * option read + string work for a page where the widget never renders.
 *
 * @return void
 */
function neccospeak_widget_custom_css() {
	if ( ! function_exists( 'wp_style_is' ) || ! wp_style_is( 'neccospeak', 'enqueued' ) ) {
		return;
	}
	$raw = function_exists( 'neccospeak_option' ) ? (string) neccospeak_option( 'custom_css', '' ) : '';
	$css = neccospeak_widget_custom_css_escape( $raw );
	if ( '' === $css ) {
		return;
	}
	if ( function_exists( 'wp_add_inline_style' ) ) {
		wp_add_inline_style( 'neccospeak', $css );
	}
}
add_action( 'wp_enqueue_scripts', 'neccospeak_widget_custom_css', 20 );

/**
 * Whether a front-end module asset file actually exists on disk (design
 * phase4 §2.8). Used so the 3 new Phase 4 module handles below never point
 * enqueue at a 404 during parallel development (e.g. impl-cta's
 * widget-cta.js landing before/after this file does) — the `path` key is
 * always returned regardless (existing consumers expect both `path` and
 * `enabled` to always be present), only `enabled` is gated on the file
 * actually being present.
 *
 * @param string $path Relative path from NECCOSPEAK_DIR.
 * @return bool
 */
function neccospeak_widget_module_exists( $path ) {
	return defined( 'NECCOSPEAK_DIR' ) && file_exists( NECCOSPEAK_DIR . $path );
}

/**
 * Build the list of optional front-end feature-module <script> handles
 * (design rev.2 §2.1 / §2.2). Each entry's 'enabled' flag mirrors
 * neccospeak_widget_config()'s own feature flags so the enqueue and the
 * localized config can never disagree about whether a module is active.
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return array<string,array{path:string,enabled:bool}> Keyed by script handle.
 */
function neccospeak_widget_modules( $opts ) {
	$cfg = neccospeak_widget_config( $opts );
	$f   = $cfg['features'];

	return array(
		'neccospeak-markdown'   => array(
			'path'    => 'assets/js/widget-markdown.js',
			'enabled' => $f['markdown']['enabled'],
		),
		'neccospeak-session'    => array(
			'path'    => 'assets/js/widget-session.js',
			'enabled' => $f['session']['enabled'],
		),
		// Phase 7d (docs/phase7d-plan.md §4 B-7, item 12): always enqueued now
		// (was gated on inlineEnabled/modalEnabled/voiceNotice). Between the
		// permanent footer notice, the first-open modal, the new B-4 Home
		// notice (features.disclaimer.noticeEnabled), and the voice mic
		// tooltip, essentially every real configuration needs this module —
		// and an admin who disables all four still gets a harmless no-op
		// setup() (each sub-feature's own 'enabled' check inside the module
		// short-circuits, exactly as before this change).
		'neccospeak-disclaimer' => array(
			'path'    => 'assets/js/widget-disclaimer.js',
			'enabled' => true,
		),
		// Always enqueued: G35 (feature detection) is a baseline safety net
		// every page needs, regardless of any option (design rev.2 §2.1).
		'neccospeak-resilience' => array(
			'path'    => 'assets/js/widget-resilience.js',
			'enabled' => true,
		),
		'neccospeak-home'       => array(
			'path'    => 'assets/js/widget-home.js',
			'enabled' => $f['home']['enabled'],
		),
		'neccospeak-pii'        => array(
			'path'    => 'assets/js/widget-pii.js',
			'enabled' => $f['pii']['warn'],
		),
		// Phase 4 (design phase4 §2.8): 3 new handles. Each is additionally
		// gated on neccospeak_widget_module_exists() so a partially-deployed
		// upload (or mid-development checkout) never enqueues a 404.
		'neccospeak-cta'        => array(
			'path'    => 'assets/js/widget-cta.js',
			'enabled' => $f['cta']['enabled'] && ! empty( $f['cta']['items'] ) && neccospeak_widget_module_exists( 'assets/js/widget-cta.js' ),
		),
		'neccospeak-ga'         => array(
			'path'    => 'assets/js/widget-ga.js',
			'enabled' => $f['ga']['enabled'] && neccospeak_widget_module_exists( 'assets/js/widget-ga.js' ),
		),
		'neccospeak-voice-ui'   => array(
			'path'    => 'assets/js/widget-voice-ui.js',
			'enabled' => $f['voiceUi']['enabled'] && neccospeak_widget_module_exists( 'assets/js/widget-voice-ui.js' ),
		),
		// Phase 5 (design phase5 §10.5): gated on
		// neccospeak_widget_module_exists() so a partially-deployed upload
		// (or mid-development checkout while impl-widgets' files are still
		// landing) never enqueues a 404. With every Phase 5 option left at
		// its default (analytics/voice all off), the 'enabled' flag below is
		// false (INV-1).
		//
		// 2026-08 撤去記録: 対になっていた 'neccospeak-feedback' ハンドル
		// (assets/js/widget-feedback.js) は、AI応答評価機能ごと削除した。
		// 2026-08 撤去記録: この条件式の `|| $f['analytics']['chatMeta']` は、
		// 会話ログ機能撤去に伴い features.analytics.chatMeta キー自体が消えたため
		// 削除した。
		'neccospeak-analytics'  => array(
			'path'    => 'assets/js/widget-analytics.js',
			'enabled' => ( $f['analytics']['enabled'] || $f['analytics']['usageReport'] )
				&& neccospeak_widget_module_exists( 'assets/js/widget-analytics.js' ),
		),
		// Phase 6 (design phase6 §10.2): 2 new handles, each gated on
		// neccospeak_widget_module_exists() so a partially-deployed upload
		// (or mid-development checkout while impl-front's files are still
		// landing) never enqueues a 404. 'neccospeak-stream' stays enabled
		// even when stream.mode==='off' as long as pageId is non-zero: it is
		// the ONLY module allowed to touch the 'chat:requestInit' hook
		// (§4.7), and that is exactly what attaches post_id to /chat.
		'neccospeak-stream'     => array(
			'path'    => 'assets/js/widget-stream.js',
			'enabled' => ( 'off' !== $f['stream']['mode'] || ! empty( $cfg['pageId'] ) )
				&& neccospeak_widget_module_exists( 'assets/js/widget-stream.js' ),
		),
		'neccospeak-cf7'        => array(
			'path'    => 'assets/js/widget-cf7.js',
			'enabled' => $f['cf7']['enabled'] && neccospeak_widget_module_exists( 'assets/js/widget-cf7.js' ),
		),
	);
}

/**
 * Whether the current front-end request's path matches a saved 'cf7_prefill'
 * CTA row's target page (design phase6 §8.4a). Compares PATH ONLY (query
 * string / fragment ignored) after untrailingslashit() on both sides, so
 * "/contact" and "/contact/?x=1" are the same target. Deliberately issues
 * ZERO database queries (no url_to_postid(), no get_queried_object()) — same
 * "no extra query on every page load" discipline as
 * includes/page-context.php's own URL-matching helpers (§4.2/§4.5).
 *
 * @param array $opts neccospeak_get_options() (or any subset/superset array).
 * @return bool
 */
function neccospeak_cf7_is_target_page( $opts ) {
	if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only path comparison, no output, no DB query.
		return false;
	}
	$current_path = wp_parse_url( (string) $_SERVER['REQUEST_URI'], PHP_URL_PATH );
	if ( ! is_string( $current_path ) || '' === $current_path ) {
		return false;
	}
	$current_path = untrailingslashit( $current_path );

	foreach ( neccospeak_widget_cta_items( $opts ) as $item ) {
		if ( ! isset( $item['type'], $item['value'] ) || 'cf7_prefill' !== $item['type'] ) {
			continue;
		}
		$target_path = wp_parse_url( (string) $item['value'], PHP_URL_PATH );
		if ( ! is_string( $target_path ) || '' === $target_path ) {
			continue;
		}
		if ( untrailingslashit( $target_path ) === $current_path ) {
			return true;
		}
	}
	return false;
}

/**
 * Enqueue the CF7 prefill "receiver" (assets/js/widget-cf7.js) on a
 * cf7_prefill target page even when the chat widget itself is hidden there
 * (design phase6 §8.4a / D8) — the receiver reads sessionStorage and fills in
 * the CF7 form fields, entirely independent of ctx/NeccoSpeakModules, so it
 * needs neither neccospeak_should_display() nor the core script to be
 * enqueued at all.
 *
 * Hooked at 'wp_enqueue_scripts' priority 11 — after neccospeak_enqueue()
 * (priority 10, neccospeak.php), so wp_script_is() can tell whether the
 * 'neccospeak-cf7' handle was already queued as a regular feature module
 * (i.e. the widget IS shown on this page too) and skip the duplicate
 * enqueue.
 *
 * @return void
 */
function neccospeak_enqueue_cf7_receiver() {
	if ( ! function_exists( 'neccospeak_get_options' ) ) {
		return;
	}
	$opts = neccospeak_get_options();
	if ( empty( $opts['enabled'] ) || empty( $opts['cf7_prefill_enabled'] ) ) {
		return;
	}
	if ( ! neccospeak_cf7_active() || ! neccospeak_widget_has_cf7_cta( $opts ) ) {
		return;
	}
	// Every WP-core enqueue function below is called behind function_exists()
	// (same discipline as neccospeak_widget_custom_css() above) so this
	// function stays directly callable from a plain-PHP test harness that has
	// not shimmed the full wp_enqueue_scripts machinery.
	if ( function_exists( 'wp_script_is' ) && wp_script_is( 'neccospeak-cf7', 'enqueued' ) ) {
		return; // Already queued as a regular feature module (§10.2) — the widget is shown here too.
	}
	if ( ! neccospeak_cf7_is_target_page( $opts ) ) {
		return;
	}
	if ( ! neccospeak_widget_module_exists( 'assets/js/widget-cf7.js' ) ) {
		return;
	}
	if ( ! function_exists( 'wp_enqueue_script' ) ) {
		return;
	}

	wp_enqueue_script(
		'neccospeak-cf7',
		( defined( 'NECCOSPEAK_URL' ) ? NECCOSPEAK_URL : '' ) . 'assets/js/widget-cf7.js',
		array(),
		defined( 'NECCOSPEAK_VERSION' ) ? NECCOSPEAK_VERSION : '',
		true
	);
	if ( function_exists( 'wp_add_inline_script' ) ) {
		wp_add_inline_script(
			'neccospeak-cf7',
			'window.neccoSpeakCf7=' . wp_json_encode( array( 'ttlMs' => defined( 'NECCOSPEAK_CF7_HANDOFF_TTL_MS' ) ? NECCOSPEAK_CF7_HANDOFF_TTL_MS : 300000 ) ) . ';',
			'before'
		);
	}
}
add_action( 'wp_enqueue_scripts', 'neccospeak_enqueue_cf7_receiver', 11 );
