/**
 * NeccoSpeak agent-ui — admin JS.
 *
 * ai-agent-embed v1.5.1.1 の管理画面JS (assets/js/admin-agent-settings.js,
 * 3800行超) はバックエンドAdmin API (wp_ajax_ai_agent_*) 前提のAJAX保存UI。
 * NeccoSpeak agent-ui は既存の Settings API (<form action="options.php">)
 * をそのまま再利用する素の送信方式のため、この巨大JSは移植せず、
 * production markup が要求する最低限のクライアント側の見た目調整だけを
 * 新規に書き起こす:
 *   - color input <-> text input の相互同期 (*_text サフィックス規約)
 *   - .ai-agent-detail-section (<details>) の見た目は素の <details> で足りる
 *   - ダッシュボードタブの「接続状態」カード: REST /ping への到達性チェック
 *   - 0.3.0-dev.38 (フェーズ2): アイコンピッカー / CTA カード一覧＋モーダル
 *     — 本番は同じ UI を AJAX 保存で実現しているが、ここでは hidden input を
 *     書き換えるだけのクライアントサイド UI として実装し、永続化は従来の
 *     <form action="options.php"> POST に任せる (下の各節の説明を参照)
 *
 * window.neccoSpeakAgentUi (wp_localize_script, includes/agent-ui.php) の
 * pingUrl / i18n を利用する。
 *
 * @package NeccoSpeak
 */
( function () {
	'use strict';

	/**
	 * IME 変換確定の Enter / Escape を弾くためのガード。
	 *
	 * 日本語などの IME は変換の確定に Enter、変換の取り消しに Escape を
	 * 使うため、素の keydown でそれらを拾うと「変換を操作しただけ」の
	 * 一打が適用/決定/キャンセルとして走ってしまう。英語環境では
	 * composition イベント自体が発生しないので、このガードは常に素通り
	 * する (Enter で確定する体感は従来どおり)。
	 *
	 * composition 系イベントはバブルするので、個々の入力欄ではなく共通の
	 * 親に 1 回張れば足りる。
	 *
	 * e.isComposing だけでは Safari など一部環境で取りこぼすため、IME 経由
	 * の keydown が使う keyCode 229 と自前フラグを併用した 3 段構えにする
	 * (widget-frame 側のチャット入力欄
	 * assets/widget-frame/js/openai-realtime-agent/widget-app-persistent.js
	 * と同じ考え方)。
	 *
	 * @param {Element} root composition を監視する要素 (入力欄の共通の親でよい)。
	 * @return {function(KeyboardEvent):boolean} 合成中の打鍵なら true。
	 */
	function createImeGuard( root ) {
		var composing = false;
		root.addEventListener( 'compositionstart', function () {
			composing = true;
		} );
		root.addEventListener( 'compositionend', function () {
			composing = false;
		} );
		return function ( e ) {
			return !! ( e.isComposing || e.keyCode === 229 || composing );
		};
	}

	function syncColorPairs( root ) {
		var colorInputs = root.querySelectorAll( 'input[type="color"][id]' );
		colorInputs.forEach( function ( picker ) {
			var text = document.getElementById( picker.id + '-text' );
			if ( ! text ) {
				return;
			}
			picker.addEventListener( 'input', function () {
				text.value = picker.value;
			} );
			text.addEventListener( 'input', function () {
				if ( /^#[0-9a-fA-F]{6}$/.test( text.value ) ) {
					picker.value = text.value;
				}
			} );
		} );
	}

	function setPingStatus( el, ok, label ) {
		var dot = el.querySelector( '.ai-agent-status-dot' );
		if ( dot ) {
			dot.className = 'ai-agent-status-dot ' + ( ok ? 'ai-agent-status-dot--ok' : 'ai-agent-status-dot--error' );
		}
		el.lastChild.textContent = ' ' + label;
	}

	function initPingCheck() {
		var el = document.getElementById( 'neccospeak-agent-ui-ping-status' );
		if ( ! el || ! window.neccoSpeakAgentUi || ! window.neccoSpeakAgentUi.pingUrl ) {
			return;
		}
		var i18n = window.neccoSpeakAgentUi.i18n || {};
		fetch( window.neccoSpeakAgentUi.pingUrl, { credentials: 'same-origin' } )
			.then( function ( res ) {
				setPingStatus( el, res.ok, res.ok ? ( i18n.reachable || 'OK' ) : ( i18n.unreachable || 'Error' ) );
			} )
			.catch( function () {
				setPingStatus( el, false, i18n.unreachable || 'Error' );
			} );
	}

	/**
	 * 「モデル・詳細設定」タブの使用モデル<select> (includes/agent-ui-tabs/tab-model.php
	 * #ai-agent-adv-model, name="...[model]") と「音声設定」タブの Realtime API
	 * モデル<select> (includes/agent-ui-tabs/tab-voice.php
	 * #neccospeak_realtime_model, name="...[realtime_model]") を、どちらも REST
	 * /neccospeak/v1/models から取得したライブのモデル一覧で埋める共通処理
	 * (2026-08、音声側はテキスト側にならって新規追加 — 元は直書き3候補の
	 * <datalist> だったため、現在値が前方一致しないと選択肢が一切出ない罠が
	 * あった)。production (ai-agent-embed admin-agent-settings.js) の同名
	 * #ai-agent-adv-model / #ai-agent-adv-model-status と同じ id をテキスト側に
	 * 使い、取得失敗時は現在の選択肢だけを残して劣化する
	 * (サーバー描画時点のオプションに触れない = 何も壊さない)。
	 *
	 * @param {string} selectId id of the <select> element to populate.
	 * @param {string} statusId id of the adjacent status <span> (optional; may
	 *                           not exist for every caller).
	 * @param {string} dataKey  which array in the /models response to use
	 *                           ('chat' | 'realtime').
	 */
	function initModelSelect( selectId, statusId, dataKey ) {
		var sel = document.getElementById( selectId );
		if ( ! sel || ! window.neccoSpeakAgentUi || ! window.neccoSpeakAgentUi.modelsUrl ) {
			return;
		}
		var status  = statusId ? document.getElementById( statusId ) : null;
		var i18n    = window.neccoSpeakAgentUi.i18n || {};
		var current = sel.value;

		var setStatus = function ( text ) {
			if ( status ) {
				status.textContent = text || '';
			}
		};

		fetch( window.neccoSpeakAgentUi.modelsUrl, {
			credentials: 'same-origin',
			headers: { 'X-WP-Nonce': window.neccoSpeakAgentUi.restNonce || '' }
		} )
			.then( function ( res ) {
				if ( ! res.ok ) {
					throw new Error( 'HTTP ' + res.status );
				}
				return res.json();
			} )
			.then( function ( data ) {
				var models = ( data && Array.isArray( data[ dataKey ] ) ) ? data[ dataKey ] : [];
				if ( models.length === 0 ) {
					setStatus( i18n.modelLoadError || '' );
					return;
				}

				sel.innerHTML = '';
				models.forEach( function ( id ) {
					var opt = document.createElement( 'option' );
					opt.value = id;
					opt.textContent = id;
					sel.appendChild( opt );
				} );

				// 現在の選択値を維持: 一覧内にあればそれを選択、無ければ先頭に追加。
				if ( current ) {
					if ( models.indexOf( current ) === -1 ) {
						var custom = document.createElement( 'option' );
						custom.value = current;
						custom.textContent = current;
						sel.insertBefore( custom, sel.firstChild );
					}
					sel.value = current;
				}

				setStatus( '' );
			} )
			.catch( function () {
				// 劣化: 取得済みの現在値だけを残し、それ以外は触らない。
				setStatus( i18n.modelFetchError || '' );
			} );
	}

	/**
	 * サブタブ（タブ内タブ）切替。production (ai-agent-embed
	 * admin-agent-settings.js の .ai-agent-subtab-btn クリックハンドラ) と同挙動:
	 * ボタンの属する .ai-agent-subtab-wrapper 内で --active クラスを付け替え、
	 * data-subtab に一致する data-subtab-panel だけを表示、他は非表示にする。
	 * production は jQuery の document 委譲だが、agent-ui.js は素のJSのため
	 * 描画済みボタンへの直接バインドで書き起こす (サブタブは全てサーバー描画で、
	 * 後から動的追加されることはない)。
	 */
	function initSubtabs( root ) {
		var buttons = root.querySelectorAll( '.ai-agent-subtab-btn' );
		buttons.forEach( function ( btn ) {
			btn.addEventListener( 'click', function () {
				var wrapper = btn.closest( '.ai-agent-subtab-wrapper' );
				if ( ! wrapper ) {
					return;
				}
				var target = btn.getAttribute( 'data-subtab' );
				wrapper.querySelectorAll( '.ai-agent-subtab-btn' ).forEach( function ( other ) {
					other.classList.remove( 'ai-agent-subtab-btn--active' );
				} );
				btn.classList.add( 'ai-agent-subtab-btn--active' );
				wrapper.querySelectorAll( '.ai-agent-subtab-panel' ).forEach( function ( panel ) {
					panel.style.display = ( panel.getAttribute( 'data-subtab-panel' ) === target ) ? '' : 'none';
				} );
			} );
		} );
	}

	/* ── プレビュータブ: ランチャー（展開前）の静的描画（本番
	 * renderLauncherHtml のローカルJSON版 vanilla 移植）。
	 * ウィジェット（展開後）はサーバ側 (includes/agent-ui-tabs/
	 * tab-preview.php) が実ウィジェット文書を iframe srcdoc で描画するため
	 * JS の出番はない ── */

	/**
	 * HTML エスケープ。本番 admin-agent-settings.js の esc() は DOM 経由だが、
	 * ここは widget-loader.js escapeHtml() と同じ replace 方式で書き起こす
	 * （出力は同一・DOM 不要なのでテストから素で呼べる）。
	 */
	function esc( s ) {
		if ( s == null ) {
			return '';
		}
		return String( s ).replace( /[&<>"']/g, function ( c ) {
			return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ c ];
		} );
	}

	/** widget-loader.js hexToRgb()/hexToRgba() の移植（3桁/6桁 hex 対応）。 */
	function hexToRgba( hex, alpha ) {
		try {
			var c = String( hex ).replace( /^#/, '' );
			if ( c.length === 3 ) {
				c = c[ 0 ] + c[ 0 ] + c[ 1 ] + c[ 1 ] + c[ 2 ] + c[ 2 ];
			}
			if ( ! /^[0-9a-fA-F]{6}$/.test( c ) ) {
				return '';
			}
			var n = parseInt( c, 16 );
			return 'rgba(' + ( ( n >> 16 ) & 255 ) + ',' + ( ( n >> 8 ) & 255 ) + ',' + ( n & 255 ) + ',' + alpha + ')';
		} catch ( e ) {
			return '';
		}
	}

	/**
	 * ランチャーボタン（展開前）の静的HTML。data は
	 * neccospeak_frame_launcher_config() の戻り値 = 実フロントの
	 * window.NECCOSPEAK_LAUNCHER と同一形状。見た目の忠実性の基準は
	 * assets/widget-frame/js/widget-loader.js の injectStyles()/createDOM()/
	 * applyLauncherConfig() が実フロントに出す DOM（サイズ/形状/色/影/
	 * アイコンSVG/ラベル/バッジ/ツールチップ）。挙動は再現しない
	 * (cursor:default、hover/クリックなし)。
	 *
	 * @param {object} data - { ui: {primary_color}, launcher: {...} }
	 * @return {string} tooltip + button の HTML 文字列。
	 */
	function renderLauncherPreviewHtml( data ) {
		var launcher = ( data && data.launcher ) || {};
		var ui       = ( data && data.ui ) || {};

		// 既定値は widget-loader.js injectStyles() の CSS 変数既定と同値
		// (--ai-agent-primary / --ai-agent-launcher-size / icon-size)。
		var primary   = ui.primary_color || '#0D9488';
		var size      = launcher.size || 48;
		var iconSize  = launcher.icon_size || 24;
		var shape     = launcher.shape || 'pill';
		var hideLabel = !! launcher.hide_label || 'circle' === shape;
		// NS は常に非空ラベルを供給する（PHP側で __() 済み）ため、この英語
		// フォールバックが表示されるのは config 欠損時のみ (INV-12)。
		var label  = launcher.label || 'Ask AI';
		var radius = 'circle' === shape ? '50%' : ( 'square' === shape ? '12px' : '9999px' );
		var shadow = hexToRgba( primary, 0.35 ) || 'rgba(13,148,136,0.35)';

		var iconHtml;
		if ( 'image' === launcher.icon_type && launcher.icon_image_url ) {
			// .ai-agent-toggle__icon--img 相当。
			iconHtml = '<img src="' + esc( launcher.icon_image_url ) + '" alt="" style="display:block;flex-shrink:0;width:' + iconSize + 'px;height:' + iconSize + 'px;object-fit:contain;border-radius:3px;" onerror="this.style.display=\'none\'">';
		} else if ( launcher.icon_svg ) {
			// icon_svg は neccospeak_frame_launcher_icon_svg() がサーバ側で
			// 組み立てた固定テーブル由来の SVG（管理者入力の生 SVG ではない）。
			iconHtml = '<span style="display:flex;align-items:center;justify-content:center;flex-shrink:0;width:' + iconSize + 'px;height:' + iconSize + 'px;">' + launcher.icon_svg + '</span>';
		} else {
			// createDOM() の初期アイコン（custom アイコンで画像未設定のときの
			// 実フロントのフォールバックと同じ吹き出し SVG）。
			iconHtml = '<svg width="' + iconSize + '" height="' + iconSize + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="display:block;flex-shrink:0;"><path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z"/></svg>';
		}

		var labelHtml = hideLabel ? '' : '<span style="font-size:14px;font-weight:600;white-space:nowrap;line-height:1;">' + esc( label ) + '</span>';

		var badgeHtml = '';
		if ( launcher.badge && launcher.badge.enabled ) {
			// .ai-agent-toggle__badge / --text 相当（NS に badge 色設定は無く
			// 実フロント同様 #10B981 固定）。
			badgeHtml = launcher.badge.text
				? '<span style="position:absolute;top:-4px;right:-4px;background:#10B981;color:#fff;font-size:10px;font-weight:600;padding:2px 6px;border-radius:10px;line-height:1.2;white-space:nowrap;">' + esc( launcher.badge.text ) + '</span>'
				: '<span style="position:absolute;top:2px;right:2px;width:10px;height:10px;border-radius:50%;background:#10B981;border:2px solid #fff;"></span>';
		}

		var tooltipHtml = '';
		if ( launcher.tooltip && launcher.tooltip.enabled && launcher.tooltip.text ) {
			// .ai-agent-tooltip / __arrow 相当（NS の tooltip は text のみ）。
			tooltipHtml = '<div style="position:absolute;bottom:calc(100% + 12px);right:0;background:#fff;color:#1F2937;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:13px;line-height:1.5;padding:10px 14px;border-radius:12px;box-shadow:0 4px 16px rgba(0,0,0,0.12);white-space:nowrap;">'
				+ esc( launcher.tooltip.text )
				+ '<span style="position:absolute;bottom:-6px;right:24px;width:12px;height:12px;background:#fff;transform:rotate(45deg);box-shadow:2px 2px 4px rgba(0,0,0,0.06);border-radius:0 0 2px 0;"></span>'
				+ '</div>';
		}

		// .ai-agent-toggle 相当（骨格スタイルを inline に展開）。
		var btnStyle = 'position:relative;appearance:none;cursor:default;display:flex;align-items:center;justify-content:center;gap:8px;'
			+ 'height:' + size + 'px;'
			+ ( hideLabel ? 'width:' + size + 'px;padding:0;' : 'padding:0 16px 0 12px;' )
			+ 'border-radius:' + radius + ';background:' + esc( primary ) + ';border:none;color:#fff;'
			+ 'box-shadow:0 4px 16px ' + shadow + ';'
			+ 'font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif;font-size:14px;line-height:1;';

		return tooltipHtml + '<button type="button" style="' + btnStyle + '">' + iconHtml + labelHtml + badgeHtml + '</button>';
	}

	/**
	 * プレビュータブ初期化: #neccospeak-agent-ui-preview-config の JSON
	 * (includes/agent-ui-tabs/tab-preview.php が埋め込む) を読み、ランチャーを
	 * ローカル描画する。fetch/AJAX なし。失敗時は本番 loadPreview() の fail
	 * 表示と同文の「読込失敗」を出す。ウィジェット（展開後）の
	 * #ai-agent-panel-preview はサーバ描画の iframe srcdoc（実ウィジェット
	 * 文書）なので、この関数は触らない。
	 */
	function initPreviewTab() {
		var inner = document.getElementById( 'ai-agent-launcher-preview-inner' );
		if ( ! inner ) {
			return;
		}

		var cfg   = null;
		var cfgEl = document.getElementById( 'neccospeak-agent-ui-preview-config' );
		if ( cfgEl ) {
			try {
				cfg = JSON.parse( cfgEl.textContent );
			} catch ( e ) {
				cfg = null;
			}
		}
		if ( ! cfg ) {
			var failText = ( window.neccoSpeakAgentUi && window.neccoSpeakAgentUi.i18n && window.neccoSpeakAgentUi.i18n.previewLoadFailed ) || 'Failed to load';
			inner.innerHTML = '<span style="color:#999;font-size:12px;"></span>';
			inner.firstChild.textContent = failText;
			return;
		}

		var launcher = ( cfg.launcher && cfg.launcher.launcher ) || {};
		var pos      = launcher.position || {};
		inner.style.bottom = ( pos.bottom != null ? pos.bottom : 24 ) + 'px';
		if ( pos.left != null ) {
			// bottom-left 配置 (widget-loader.js applyLauncherConfig の
			// --ai-agent-launcher-left 分岐と同じ左アンカー切替)。
			inner.style.left  = pos.left + 'px';
			inner.style.right = 'auto';
		} else {
			inner.style.right = ( pos.right != null ? pos.right : 24 ) + 'px';
		}
		inner.innerHTML = renderLauncherPreviewHtml( cfg.launcher );
	}

	/* ═════════════════════════════════════════════════════════════════════
	 * 0.3.0-dev.38（会話フロー本番パリティ フェーズ2 §1）— アイコンピッカー
	 *
	 * 本番 ai-agent-embed の admin-agent-settings.js は jQuery 委譲で
	 * 「検索 input ＋ 6列グリッド」のフロートパネルを body に生やし、
	 * .ai-agent-qa-icon-btn の隣の hidden (.ai-agent-qa-icon) に値を書く。
	 * ここではそれを素の JS に書き起こし、アイコン名の語彙だけを差し替える:
	 * 本番は約60件のハンドメイド配列だが、NS は同梱の bootstrap-icons CSS を
	 * PHP 側（neccospeak_agent_ui_icon_names()）でパースした全 2078 件を
	 * window.neccoSpeakAgentUi.icons として受け取る。
	 *
	 * DOM 契約は includes/agent-ui.php の neccospeak_agent_ui_icon_field()。
	 * ═════════════════════════════════════════════════════════════════════ */

	/** 一度に描画するグリッドのセル数上限（2078 件を素で描くと重い）。 */
	var ICON_GRID_LIMIT = 300;

	/**
	 * NS の 15 値アイコン enum → bootstrap-icons サフィックスの対応表。
	 * includes/widget-frame.php の neccospeak_frame_chip_icon_bi() の写しで、
	 * 「保存済みの古い行が enum 名を持っている」ケースのグリフ描画に使う
	 * （ピッカーが新しく書き込む値は常に生の bi- 名）。
	 */
	var ICON_ALIASES = {
		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: 'briefcase-fill',
		building: 'building',
		chart: 'bar-chart-fill'
	};

	/** neccospeak_sanitize_cta_list() / neccospeak_frame_chip_icon_bi() と同じ許可形。 */
	var ICON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,49}$/;

	function i18n( key, fallback ) {
		var strings = ( window.neccoSpeakAgentUi && window.neccoSpeakAgentUi.i18n ) || {};
		return ( typeof strings[ key ] === 'string' && strings[ key ] !== '' ) ? strings[ key ] : fallback;
	}

	function iconNames() {
		var cfg = window.neccoSpeakAgentUi || {};
		return Array.isArray( cfg.icons ) ? cfg.icons : [];
	}

	/**
	 * 保存値 → 実際に描けるグリフ名。'none'/空/形が不正なものは '' に落とす
	 * （'' はプレースホルダ「選択」を描く合図）。
	 */
	function iconGlyph( name ) {
		var value = ( name == null ) ? '' : String( name );
		if ( value === '' || value === 'none' ) {
			return '';
		}
		if ( Object.prototype.hasOwnProperty.call( ICON_ALIASES, value ) ) {
			return ICON_ALIASES[ value ];
		}
		return ICON_NAME_RE.test( value ) ? value : '';
	}

	/** 数値1個だけの %d 書式（wp_localize_script 経由の書式文字列用）。 */
	function formatNumber( template, number ) {
		return String( template ).replace( '%d', String( number ) ).replace( '%1$d', String( number ) );
	}

	/**
	 * 1つのアイコンフィールド（[data-neccospeak-icon-field]）に値を反映する。
	 * hidden input の値・ボタンのグリフ・名前のテキスト・クリアボタンの
	 * 表示を一括で更新し、change を bubbling で飛ばす。
	 */
	function applyIconValue( field, name ) {
		if ( ! field ) {
			return;
		}
		var input = field.querySelector( '[data-neccospeak-icon-value]' );
		if ( ! input ) {
			return;
		}
		var value = ( name == null ) ? '' : String( name );
		var glyph = iconGlyph( value );

		input.value = value;

		var btn = field.querySelector( '[data-neccospeak-icon-btn]' );
		if ( btn ) {
			while ( btn.firstChild ) {
				btn.removeChild( btn.firstChild );
			}
			if ( glyph !== '' ) {
				var glyphEl = document.createElement( 'i' );
				glyphEl.className = 'bi bi-' + glyph;
				glyphEl.setAttribute( 'aria-hidden', 'true' );
				btn.appendChild( glyphEl );
			} else {
				var placeholder = document.createElement( 'span' );
				placeholder.className = 'neccospeak-icon-btn__placeholder';
				placeholder.textContent = i18n( 'iconSelect', 'Select' );
				btn.appendChild( placeholder );
			}
			// dev.67: インラインの名前表示を隠したため、選択中のアイコン名は
			// ボタンの title(ツールチップ)で読めるようにする。aria-label は
			// 「アイコンを選択」の役割説明のまま据え置き。
			btn.title = ( glyph !== '' ) ? value : i18n( 'iconPickerTitle', 'Choose an icon' );
		}

		var nameEl = field.querySelector( '[data-neccospeak-icon-name]' );
		if ( nameEl ) {
			nameEl.textContent = ( value === 'none' ) ? '' : value;
		}

		var clearBtn = field.querySelector( '[data-neccospeak-icon-clear]' );
		if ( clearBtn ) {
			clearBtn.hidden = ( glyph === '' );
		}

		try {
			input.dispatchEvent( new Event( 'change', { bubbles: true } ) );
		} catch ( e ) {
			// Event コンストラクタが無い環境: 反映そのものは済んでいるので黙って続行。
		}
	}

	/** 現在開いているピッカーの状態（ページに1つだけ生やす）。 */
	var iconPicker = {
		panel: null,
		search: null,
		grid: null,
		empty: null,
		count: null,
		field: null,
		trigger: null
	};

	function buildIconPicker() {
		var panel = document.createElement( 'div' );
		panel.className = 'neccospeak-icon-picker';
		panel.setAttribute( 'role', 'dialog' );
		panel.setAttribute( 'aria-label', i18n( 'iconPickerTitle', 'Choose an icon' ) );
		panel.hidden = true;

		var search = document.createElement( 'input' );
		search.type = 'search';
		search.className = 'neccospeak-icon-picker__search';
		search.placeholder = i18n( 'iconSearchPlaceholder', 'Search icons…' );
		search.setAttribute( 'aria-label', i18n( 'iconSearchPlaceholder', 'Search icons…' ) );

		var hint = document.createElement( 'p' );
		hint.className = 'neccospeak-icon-picker__hint';
		hint.textContent = i18n( 'iconUseTyped', 'Enter applies the first match — or the exact name you typed.' );

		// dev.67: 行内の「クリア」リンクを隠したため、アイコンを外す操作は
		// ピッカー内のこのボタンが担う(chooseIcon('') = 値を空にして閉じる)。
		var clearChoice = document.createElement( 'button' );
		clearChoice.type = 'button';
		clearChoice.className = 'neccospeak-icon-picker__clear';
		clearChoice.textContent = i18n( 'iconClearChoice', 'No icon (clear)' );
		clearChoice.addEventListener( 'click', function ( e ) {
			e.preventDefault();
			chooseIcon( '' );
		} );

		var grid = document.createElement( 'div' );
		grid.className = 'neccospeak-icon-picker__grid';

		var empty = document.createElement( 'p' );
		empty.className = 'neccospeak-icon-picker__empty';
		empty.textContent = i18n( 'iconNoMatch', 'No icons match your search.' );
		empty.hidden = true;

		var count = document.createElement( 'p' );
		count.className = 'neccospeak-icon-picker__count';

		panel.appendChild( search );
		panel.appendChild( hint );
		panel.appendChild( clearChoice );
		panel.appendChild( grid );
		panel.appendChild( empty );
		panel.appendChild( count );
		document.body.appendChild( panel );

		iconPicker.panel = panel;
		iconPicker.search = search;
		iconPicker.grid = grid;
		iconPicker.empty = empty;
		iconPicker.count = count;

		search.addEventListener( 'input', function () {
			renderIconGrid( search.value );
		} );
		var searchIsComposing = createImeGuard( search );
		search.addEventListener( 'keydown', function ( e ) {
			if ( e.key !== 'Enter' ) {
				return;
			}
			// IME 変換確定の Enter でアイコンを選んで閉じてしまわないように。
			if ( searchIsComposing( e ) ) {
				return;
			}
			e.preventDefault();
			var typed = String( search.value ).trim().toLowerCase();
			var first = grid.querySelector( '[data-icon]' );
			if ( typed !== '' && iconNames().indexOf( typed ) !== -1 ) {
				chooseIcon( typed );
			} else if ( first ) {
				chooseIcon( first.getAttribute( 'data-icon' ) );
			} else if ( ICON_NAME_RE.test( typed ) ) {
				// 逃げ道: 同梱CSSが読めず icons が空でも手入力で設定できる。
				chooseIcon( typed );
			}
		} );

		grid.addEventListener( 'click', function ( e ) {
			var cell = e.target && e.target.closest ? e.target.closest( '[data-icon]' ) : null;
			if ( ! cell ) {
				return;
			}
			e.preventDefault();
			chooseIcon( cell.getAttribute( 'data-icon' ) );
		} );

		return panel;
	}

	function renderIconGrid( query ) {
		var grid = iconPicker.grid;
		if ( ! grid ) {
			return;
		}
		while ( grid.firstChild ) {
			grid.removeChild( grid.firstChild );
		}

		var needle = String( query == null ? '' : query ).trim().toLowerCase();
		var all = iconNames();
		// 0.3.0-dev.40: 検索はアイコン名（英語）に加えて、ローカライズ済み
		// ラベル（neccospeak_agent_ui_icon_labels() — 本番のQA_ICONS_DASH由来の
		// 厳選62件）にも一致する。日本語UIでは「電話」「仕事」等で検索できる。
		var labels = ( window.neccoSpeakAgentUi && window.neccoSpeakAgentUi.iconLabels ) || {};
		var matches = needle === '' ? all : all.filter( function ( name ) {
			if ( name.indexOf( needle ) !== -1 ) {
				return true;
			}
			var label = labels[ name ];
			return !! label && String( label ).toLowerCase().indexOf( needle ) !== -1;
		} );

		var shown = matches.slice( 0, ICON_GRID_LIMIT );
		shown.forEach( function ( name ) {
			var cell = document.createElement( 'button' );
			cell.type = 'button';
			cell.className = 'neccospeak-icon-picker__cell';
			cell.setAttribute( 'data-icon', name );
			cell.title = labels[ name ] ? labels[ name ] + ' (' + name + ')' : name;
			cell.setAttribute( 'aria-label', labels[ name ] || name );
			var glyphEl = document.createElement( 'i' );
			glyphEl.className = 'bi bi-' + name;
			glyphEl.setAttribute( 'aria-hidden', 'true' );
			cell.appendChild( glyphEl );
			grid.appendChild( cell );
		} );

		if ( iconPicker.empty ) {
			iconPicker.empty.hidden = ( matches.length !== 0 );
		}
		if ( iconPicker.count ) {
			iconPicker.count.textContent = ( matches.length > shown.length )
				? formatNumber( i18n( 'iconShowing', 'Showing the first %d matches — keep typing to narrow the list.' ), shown.length )
				: '';
		}
	}

	function positionIconPicker() {
		var panel = iconPicker.panel;
		var trigger = iconPicker.trigger;
		if ( ! panel || ! trigger ) {
			return;
		}
		var rect = trigger.getBoundingClientRect();
		var width = panel.offsetWidth || 320;
		var height = panel.offsetHeight || 300;

		var left = Math.min( rect.left, window.innerWidth - width - 12 );
		if ( left < 8 ) {
			left = 8;
		}
		var top = rect.bottom + 4;
		if ( top + height > window.innerHeight - 8 ) {
			top = Math.max( 8, rect.top - height - 4 );
		}
		panel.style.left = left + 'px';
		panel.style.top = top + 'px';
	}

	function openIconPicker( trigger ) {
		var field = trigger.closest ? trigger.closest( '[data-neccospeak-icon-field]' ) : null;
		if ( ! field ) {
			return;
		}
		if ( ! iconPicker.panel ) {
			buildIconPicker();
		}
		iconPicker.field = field;
		iconPicker.trigger = trigger;
		iconPicker.search.value = '';
		renderIconGrid( '' );
		iconPicker.panel.hidden = false;
		positionIconPicker();
		iconPicker.search.focus();
	}

	function closeIconPicker( refocus ) {
		if ( ! iconPicker.panel || iconPicker.panel.hidden ) {
			return;
		}
		var trigger = iconPicker.trigger;
		iconPicker.panel.hidden = true;
		iconPicker.field = null;
		iconPicker.trigger = null;
		if ( refocus && trigger && trigger.focus ) {
			trigger.focus();
		}
	}

	function iconPickerIsOpen() {
		return !! ( iconPicker.panel && ! iconPicker.panel.hidden );
	}

	function chooseIcon( name ) {
		applyIconValue( iconPicker.field, name );
		closeIconPicker( true );
	}

	function initIconPickers() {
		// 委譲: リピーターが後から複製した行のアイコンフィールドにも効く。
		document.addEventListener( 'click', function ( e ) {
			var target = e.target;
			if ( ! target || ! target.closest ) {
				return;
			}

			var openBtn = target.closest( '[data-neccospeak-icon-btn]' );
			if ( openBtn ) {
				e.preventDefault();
				if ( iconPickerIsOpen() && iconPicker.trigger === openBtn ) {
					closeIconPicker( true );
				} else {
					openIconPicker( openBtn );
				}
				return;
			}

			var clearBtn = target.closest( '[data-neccospeak-icon-clear]' );
			if ( clearBtn ) {
				e.preventDefault();
				applyIconValue( clearBtn.closest( '[data-neccospeak-icon-field]' ), '' );
				return;
			}

			if ( iconPickerIsOpen() && ! target.closest( '.neccospeak-icon-picker' ) ) {
				closeIconPicker( false );
			}
		} );

		document.addEventListener( 'keydown', function ( e ) {
			if ( e.key === 'Escape' && iconPickerIsOpen() ) {
				e.stopPropagation(); // モーダルより先にピッカーだけを閉じる。
				closeIconPicker( true );
			}
		}, true );

		// パネルは position:fixed なので、下地がスクロールしたら追従させる。
		window.addEventListener( 'scroll', function () {
			if ( iconPickerIsOpen() ) {
				positionIconPicker();
			}
		}, true );
		window.addEventListener( 'resize', function () {
			if ( iconPickerIsOpen() ) {
				positionIconPicker();
			}
		} );
	}

	/* ═════════════════════════════════════════════════════════════════════
	 * 0.3.0-dev.38（フェーズ2 §2）— CTA カード一覧＋追加/編集モーダル
	 *
	 * 保存経路は一切変えない: カードが持つ 11 キー分の hidden input が
	 * これまでどおり <form action="options.php"> で POST され、モーダルは
	 * その hidden を読み書きするだけのクライアントサイド UI。したがって
	 * この JS が読み込まれなくても既存行は壊れず往復する（追加・編集が
	 * できなくなるだけ）。DOM 契約は
	 * includes/agent-ui-tabs/tab-flow.php の
	 * neccospeak_agent_ui_flow_cta_card() / _cta_modal()。
	 * ═════════════════════════════════════════════════════════════════════ */

	var CTA_FIELDS = [
		'label', 'icon', 'type', 'value', 'subtitle', 'slot', 'order', 'enabled',
		'cond', 'delay', 'replies', 'map.name', 'map.email', 'map.message'
	];

	function ctaClampInt( raw, min, max, fallback ) {
		var n = parseInt( raw, 10 );
		if ( isNaN( n ) ) {
			return fallback;
		}
		if ( n < min ) {
			return min;
		}
		if ( n > max ) {
			return max;
		}
		return n;
	}

	function ctaOptionLabel( select, value ) {
		if ( ! select ) {
			return value;
		}
		for ( var i = 0; i < select.options.length; i++ ) {
			if ( select.options[ i ].value === value ) {
				return select.options[ i ].text;
			}
		}
		return value;
	}

	function initCtaCards( root ) {
		var list = root.querySelector( '[data-neccospeak-cta-list]' );
		var modal = root.querySelector( '[data-neccospeak-cta-modal]' );
		var addBtn = root.querySelector( '[data-neccospeak-cta-add]' );
		if ( ! list || ! modal || ! addBtn ) {
			return;
		}

		var rowsWrap = list.querySelector( '[data-neccospeak-cta-rows]' );
		var protoWrap = list.querySelector( '[data-neccospeak-cta-proto]' );
		var emptyMsg = list.querySelector( '[data-neccospeak-cta-empty]' );
		var statusEl = root.querySelector( '[data-neccospeak-cta-status]' );
		if ( ! rowsWrap || ! protoWrap ) {
			return;
		}

		var titleEl = modal.querySelector( '[data-neccospeak-cta-modal-title]' );
		var errorEl = modal.querySelector( '[data-neccospeak-cta-error]' );
		var iconField = modal.querySelector( '[data-neccospeak-icon-field]' );
		var typeSelect = modal.querySelector( '[data-neccospeak-cta-input="type"]' );
		var condSelect = modal.querySelector( '[data-neccospeak-cta-input="cond"]' );
		var valueRow = modal.querySelector( '[data-neccospeak-cta-value-row]' );
		var cf7Row = modal.querySelector( '[data-neccospeak-cta-cf7-row]' );
		var delayWrap = modal.querySelector( '[data-neccospeak-cta-delay-wrap]' );
		var repliesWrap = modal.querySelector( '[data-neccospeak-cta-replies-wrap]' );

		var max = ctaClampInt( list.getAttribute( 'data-neccospeak-cta-max' ), 1, 99, 8 );
		var nextIndex = ctaClampInt( list.getAttribute( 'data-neccospeak-cta-next' ), 0, 9999, 0 );

		var editing = null; // { row: Element, isNew: boolean }
		var lastFocus = null;

		function setStatus( text ) {
			if ( statusEl ) {
				statusEl.textContent = text || ''; // textContent: XSS-safe
			}
		}

		function refreshEmptyState() {
			if ( emptyMsg ) {
				emptyMsg.hidden = ( rowsWrap.children.length !== 0 );
			}
		}

		function rowInput( row, key ) {
			return row.querySelector( '[data-neccospeak-cta-field="' + key + '"]' );
		}

		function readRow( row ) {
			var data = {};
			CTA_FIELDS.forEach( function ( key ) {
				var input = rowInput( row, key );
				data[ key ] = input ? String( input.value ) : '';
			} );
			return data;
		}

		function writeRow( row, data ) {
			CTA_FIELDS.forEach( function ( key ) {
				var input = rowInput( row, key );
				if ( input && Object.prototype.hasOwnProperty.call( data, key ) ) {
					input.value = data[ key ];
				}
			} );
		}

		function condText( data ) {
			if ( data.cond === 'delay' ) {
				return formatNumber( i18n( 'ctaCondDelay', 'Shown after %d seconds' ), ctaClampInt( data.delay, 1, 300, 10 ) );
			}
			if ( data.cond === 'replies' ) {
				return formatNumber( i18n( 'ctaCondReplies', 'Shown after reply %d' ), ctaClampInt( data.replies, 1, 20, 2 ) );
			}
			return i18n( 'ctaCondAlways', 'Always shown' );
		}

		function summaryEl( row, key ) {
			return row.querySelector( '[data-neccospeak-cta-summary="' + key + '"]' );
		}

		function renderCard( row, data ) {
			var dash = i18n( 'ctaEmptyValue', '—' );
			var enabled = ( data.enabled === '1' );
			var isMain = ( data.slot === 'main' );

			var badge = row.querySelector( '[data-neccospeak-cta-slot-badge]' );
			if ( badge ) {
				// バッジは <select> の長い説明つき文言ではなく、PHP から来る
				// 短いラベル（neccospeak_agent_ui_flow_cta_slot_labels() と同文）
				// を使う。長い方を「—」で切ると訳文の区切り記号に依存して壊れる。
				badge.textContent = isMain ? i18n( 'ctaSlotMain', 'Main' ) : i18n( 'ctaSlotSub', 'Sub' );
				badge.className = 'ai-agent-badge ai-agent-badge--' + ( isMain ? 'published' : 'draft' );
			}

			var glyphWrap = row.querySelector( '[data-neccospeak-cta-glyph]' );
			if ( glyphWrap ) {
				while ( glyphWrap.firstChild ) {
					glyphWrap.removeChild( glyphWrap.firstChild );
				}
				var glyph = iconGlyph( data.icon );
				if ( glyph !== '' ) {
					var glyphEl = document.createElement( 'i' );
					glyphEl.className = 'bi bi-' + glyph;
					glyphEl.setAttribute( 'aria-hidden', 'true' );
					glyphWrap.appendChild( glyphEl );
				}
			}

			var pairs = {
				label: data.label,
				type: ctaOptionLabel( typeSelect, data.type ),
				value: ( data.type === 'voice' || data.value === '' ) ? dash : data.value,
				subtitle: data.subtitle === '' ? dash : data.subtitle,
				order: data.order === '' ? dash : data.order,
				cond: condText( data )
			};
			Object.keys( pairs ).forEach( function ( key ) {
				var el = summaryEl( row, key );
				if ( el ) {
					el.textContent = pairs[ key ];
				}
			} );

			var offNote = row.querySelector( '[data-neccospeak-cta-off-note]' );
			if ( offNote ) {
				offNote.hidden = enabled;
			}
			if ( enabled ) {
				row.classList.remove( 'neccospeak-cta-card--off' );
			} else {
				row.classList.add( 'neccospeak-cta-card--off' );
			}
		}

		function showError( message ) {
			if ( errorEl ) {
				errorEl.textContent = message;
				errorEl.hidden = false;
			}
		}

		function hideError() {
			if ( errorEl ) {
				errorEl.textContent = '';
				errorEl.hidden = true;
			}
		}

		function modalInput( key ) {
			return modal.querySelector( '[data-neccospeak-cta-input="' + key + '"]' );
		}

		function syncModalVisibility() {
			var type = typeSelect ? typeSelect.value : 'link';
			var cond = condSelect ? condSelect.value : 'always';
			// 'voice' は「種別そのものが動作」で値を持たない
			// （neccospeak_sanitize_cta_list() が必ず '' に落とす）。
			if ( valueRow ) {
				valueRow.hidden = ( type === 'voice' );
			}
			if ( cf7Row ) {
				cf7Row.hidden = ( type !== 'cf7_prefill' );
			}
			if ( delayWrap ) {
				delayWrap.hidden = ( cond !== 'delay' );
			}
			if ( repliesWrap ) {
				repliesWrap.hidden = ( cond !== 'replies' );
			}
		}

		function populateModal( data ) {
			CTA_FIELDS.forEach( function ( key ) {
				if ( key === 'icon' ) {
					applyIconValue( iconField, data.icon );
					return;
				}
				var input = modalInput( key );
				if ( ! input ) {
					return;
				}
				if ( input.type === 'checkbox' ) {
					input.checked = ( data[ key ] === '1' );
				} else {
					input.value = data[ key ];
				}
			} );
			syncModalVisibility();
		}

		function collectModal() {
			var data = {};
			CTA_FIELDS.forEach( function ( key ) {
				if ( key === 'icon' ) {
					var iconInput = iconField ? iconField.querySelector( '[data-neccospeak-icon-value]' ) : null;
					data.icon = iconInput ? String( iconInput.value ) : '';
					return;
				}
				var input = modalInput( key );
				if ( ! input ) {
					data[ key ] = '';
					return;
				}
				data[ key ] = ( input.type === 'checkbox' )
					? ( input.checked ? '1' : '0' )
					: String( input.value ).trim();
			} );

			// サーバ側 (neccospeak_sanitize_cta_list()) と同じ範囲に丸めて
			// おく。保存前にカードのサマリと実際に保存される値がズレないように。
			data.icon = ( data.icon === '' ) ? 'none' : data.icon;
			data.slot = ( data.slot === 'main' ) ? 'main' : 'sub';
			data.cond = ( data.cond === 'delay' || data.cond === 'replies' ) ? data.cond : 'always';
			data.delay = String( ctaClampInt( data.delay, 1, 300, 10 ) );
			data.replies = String( ctaClampInt( data.replies, 1, 20, 2 ) );
			if ( data.order !== '' ) {
				data.order = String( ctaClampInt( data.order, 0, 99, 0 ) );
			}
			if ( data.type === 'voice' ) {
				data.value = '';
			}
			return data;
		}

		function openModal( row, isNew ) {
			editing = { row: row, isNew: !! isNew };
			lastFocus = document.activeElement;
			if ( titleEl ) {
				titleEl.textContent = isNew ? i18n( 'ctaAddTitle', 'Add CTA' ) : i18n( 'ctaEditTitle', 'Edit CTA' );
			}
			var data = readRow( row );
			if ( isNew && data.order === '' ) {
				// 雛形は order を空で持つ（サニタイザの採番に委ねる指定）。
				// 追加ダイアログでは実際に採番される位置を初期値として見せる。
				data.order = String( rowsWrap.children.length );
			}
			populateModal( data );
			hideError();
			setStatus( '' );
			modal.style.display = 'flex';
			var labelInput = modalInput( 'label' );
			if ( labelInput ) {
				labelInput.focus();
				labelInput.select();
			}
		}

		function closeModal() {
			modal.style.display = 'none';
			editing = null;
			closeIconPicker( false );
			if ( lastFocus && lastFocus.focus ) {
				lastFocus.focus();
			}
			lastFocus = null;
		}

		function activateNewRow( row ) {
			var idx = nextIndex;
			nextIndex += 1;
			var fields = row.querySelectorAll( 'input, select, textarea' );
			for ( var i = 0; i < fields.length; i++ ) {
				var fname = fields[ i ].getAttribute( 'name' );
				if ( fname ) {
					fields[ i ].setAttribute( 'name', fname.replace( '__i__', String( idx ) ) );
				}
				fields[ i ].removeAttribute( 'disabled' );
			}
			row.setAttribute( 'data-neccospeak-cta-index', String( idx ) );
		}

		function applyModal() {
			if ( ! editing ) {
				return;
			}
			var data = collectModal();
			if ( data.label === '' ) {
				showError( i18n( 'ctaLabelRequired', 'Enter a button label.' ) );
				var labelInput = modalInput( 'label' );
				if ( labelInput ) {
					labelInput.focus();
				}
				return;
			}

			var row = editing.row;
			writeRow( row, data );
			renderCard( row, data );

			if ( editing.isNew ) {
				activateNewRow( row );
				rowsWrap.appendChild( row );
				refreshEmptyState();
			}
			closeModal();
		}

		addBtn.addEventListener( 'click', function ( e ) {
			e.preventDefault();
			if ( rowsWrap.children.length >= max ) {
				setStatus( i18n( 'ctaMaxReached', 'No more CTAs can be added.' ) );
				return;
			}
			var proto = protoWrap.firstElementChild;
			if ( ! proto ) {
				return;
			}
			// 採番は「適用」時に行う（キャンセルでインデックスを浪費しない）。
			openModal( proto.cloneNode( true ), true );
		} );

		rowsWrap.addEventListener( 'click', function ( e ) {
			var target = e.target;
			if ( ! target || ! target.closest ) {
				return;
			}
			var row = target.closest( '[data-neccospeak-cta-row]' );
			if ( ! row ) {
				return;
			}
			if ( target.closest( '[data-neccospeak-cta-edit]' ) ) {
				e.preventDefault();
				openModal( row, false );
				return;
			}
			if ( target.closest( '[data-neccospeak-cta-remove]' ) ) {
				e.preventDefault();
				if ( window.confirm( i18n( 'ctaRemoveConfirm', 'Remove this CTA?' ) ) ) {
					rowsWrap.removeChild( row );
					refreshEmptyState();
					setStatus( '' );
				}
			}
		} );

		modal.addEventListener( 'click', function ( e ) {
			var target = e.target;
			if ( ! target || ! target.closest ) {
				return;
			}
			if ( target.closest( '[data-neccospeak-cta-apply]' ) ) {
				e.preventDefault();
				applyModal();
				return;
			}
			if ( target.closest( '[data-neccospeak-cta-cancel]' ) ) {
				e.preventDefault();
				closeModal();
			}
		} );

		if ( typeSelect ) {
			typeSelect.addEventListener( 'change', syncModalVisibility );
		}
		if ( condSelect ) {
			condSelect.addEventListener( 'change', syncModalVisibility );
		}

		var modalIsComposing = createImeGuard( modal );

		modal.addEventListener( 'keydown', function ( e ) {
			// IME 変換中の Enter は「変換の確定」、Escape は「変換の取り消し」
			// であって、どちらもモーダルへの操作ではない。ここで抜けないと
			// 日本語をラベル欄に打っただけでモーダルが適用/キャンセルされる。
			if ( modalIsComposing( e ) ) {
				return;
			}
			if ( e.key === 'Escape' && ! iconPickerIsOpen() ) {
				e.preventDefault();
				closeModal();
				return;
			}
			// モーダルは <form> の外にあるので Enter で送信は起きないが、
			// テキスト入力での Enter は「適用」を意味させる（本番と同じ体感）。
			if ( e.key === 'Enter' && e.target && e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' ) {
				e.preventDefault();
				applyModal();
			}
		} );

		refreshEmptyState();
		syncModalVisibility();
	}

	document.addEventListener( 'DOMContentLoaded', function () {
		syncColorPairs( document );
		initPingCheck();
		initModelSelect( 'ai-agent-adv-model', 'ai-agent-adv-model-status', 'chat' );
		initModelSelect( 'neccospeak_realtime_model', 'neccospeak-realtime-model-status', 'realtime' );
		initSubtabs( document );
		initPreviewTab();
		initIconPickers();
		initCtaCards( document );
	} );
} )();
