<?php
/**
 * NeccoSpeak — agent-ui scaffold.
 *
 * Overwrites the settings-page UI to match ai-agent-embed (production paid
 * plugin) v1.5.1.1's top-tab layout, so a free-plan user who upgrades never
 * has to relearn the settings screen. Structural/visual contract is frozen
 * across both work streams building this feature (see the task brief this
 * file was built from); this file owns:
 *   - swapping the existing NeccoSpeak admin-menu callback for the new one
 *     (the OLD callback, tab registry, and save/sanitize path in
 *     includes/settings.php are left completely untouched)
 *   - the new top-tab registry + routing (?page=neccospeak&tab={slug})
 *   - enqueuing the copied-from-production CSS/JS (assets/agent-ui/**)
 *   - small helpers every tab-{slug}.php file uses to open/close a
 *     production-styled "card" that is a REAL <form action="options.php">
 *     posting through NeccoSpeak's existing settings_fields() +
 *     neccospeak_sanitize_options() path (design rev.2 §3.4's per-tab
 *     whitelist in neccospeak_tab_fields()) -- this file/its tabs never call
 *     update_option() directly.
 *
 * Tab bodies live one-per-file in includes/agent-ui-tabs/tab-{slug}.php,
 * each defining neccospeak_agent_ui_tab_{slug}( $opts ) and echoing its
 * markup (contract shared with the tab-file owners). file_exists()-guarded
 * requires below so a partial checkout (tab files still being written in
 * parallel) never fatals the whole admin screen -- same discipline
 * neccospeak.php/includes/settings.php already use for every optional file.
 *
 * @package NeccoSpeak
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/* =========================================================================
 * 1. Top-tab registry (frozen contract: same 9 tabs/labels as
 *    ai-agent-embed v1.5.1.1's dashboard — includes/admin-agent-settings.php
 *    ai_agent_embed_render_dashboard_page()'s $tabs).
 * ========================================================================= */

/**
 * @return array<string,string> slug => label.
 */
function neccospeak_agent_ui_tabs() {
	return array(
		'dashboard' => __( 'Dashboard', 'neccospeak' ),
		'widget'    => __( 'Widget settings', 'neccospeak' ),
		'ai'        => __( 'AI settings', 'neccospeak' ),
		'flow'      => __( 'Conversation flow', 'neccospeak' ),
		'voice'     => __( 'Voice settings', 'neccospeak' ),
		'model'     => __( 'Model & advanced settings', 'neccospeak' ),
		'security'  => __( 'Security', 'neccospeak' ),
		'preview'   => __( 'Preview', 'neccospeak' ),
		'history'   => __( 'Change history', 'neccospeak' ),
	);
}

/* =========================================================================
 * 2. Menu callback swap.
 *
 * includes/settings.php's neccospeak_admin_menu() (hooked at the default
 * admin_menu priority, 10) registers add_menu_page(..., 'neccospeak_render_settings_page', ...),
 * which internally does add_action( $hook, 'neccospeak_render_settings_page' ).
 * Running at a later priority lets us swap that one add_action() call for
 * our own render function without touching includes/settings.php, without
 * re-registering the menu item (which would create a duplicate entry), and
 * without deleting/renaming neccospeak_render_settings_page() itself (still
 * referenced elsewhere per the frozen contract this file was built under).
 * ========================================================================= */

add_action( 'admin_menu', 'neccospeak_agent_ui_swap_menu_callback', 20 );

function neccospeak_agent_ui_swap_menu_callback() {
	if ( ! defined( 'NECCOSPEAK_SETTINGS_SLUG' ) ) {
		return;
	}
	$hook = 'toplevel_page_' . NECCOSPEAK_SETTINGS_SLUG;
	remove_action( $hook, 'neccospeak_render_settings_page' );
	add_action( $hook, 'neccospeak_agent_ui_render_page' );
}

/* =========================================================================
 * 3. Assets.
 * ========================================================================= */

/**
 * 0.3.0-dev.38 (会話フロー本番パリティ フェーズ2 §1): the icon vocabulary the
 * admin icon picker offers, derived from the ACTUAL bundled Bootstrap Icons
 * stylesheet rather than a hand-maintained enum.
 *
 * dev.37 made neccospeak_sanitize_cta_list() accept any raw `bi-` class
 * suffix and replaced the hand-picked 37-class CSS subset with the complete
 * upstream v1.13.1 stylesheet, precisely so the admin vocabulary could stop
 * being a curated list. Parsing `.bi-<name>::before` out of that same file is
 * what keeps "what the picker offers" and "what the bundled font can draw"
 * from ever drifting apart again: there is exactly one source of truth, and
 * dropping in a newer upstream stylesheet updates the picker for free.
 *
 * The regex is deliberately the same shape as the sanitizer's own
 * allow-pattern (`^[a-z0-9][a-z0-9-]{0,49}$`), so every name this returns is
 * guaranteed to survive a save round-trip.
 *
 * Cached in a static for the request (parsing an ~87KB stylesheet once per
 * request is cheap, and the file cannot change mid-request).
 *
 * @return string[] Sorted, de-duplicated Bootstrap Icons name suffixes. Empty
 *                  when the stylesheet is missing/unreadable (the picker then
 *                  degrades to manual entry — see assets/agent-ui/js/agent-ui.js).
 */
function neccospeak_agent_ui_icon_names() {
	static $cache = null;
	if ( null !== $cache ) {
		return $cache;
	}

	$cache = array();
	$path  = NECCOSPEAK_DIR . 'assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css';
	if ( ! is_readable( $path ) ) {
		return $cache;
	}

	// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a stylesheet bundled inside this plugin's own directory (never a remote URL, never user input); wp_remote_get() does not apply and WP_Filesystem would need credentials for a plain local read.
	$css = file_get_contents( $path );
	if ( ! is_string( $css ) || '' === $css ) {
		return $cache;
	}

	if ( preg_match_all( '/\.bi-([a-z0-9][a-z0-9-]{0,49})::before/', $css, $matches ) ) {
		$names = array_values( array_unique( $matches[1] ) );
		sort( $names );
		$cache = $names;
	}

	return $cache;
}

/**
 * よく使うアイコンの検索用ラベル（0.3.0-dev.40, 会話フロー本番パリティの
 * 補完）。本番 admin-agent-settings.js の QA_ICONS_DASH（手書き62件・日本語
 * ラベル）を、INV-12 に従い英語msgid+ja.po訳の形で移植したもの。ピッカーの
 * 検索がアイコン名（英語）に加えてこのローカライズ済みラベルにも一致する
 * ため、日本語UIでは「電話」「仕事」等で検索できる。ピッカーの語彙全体
 * （neccospeak_agent_ui_icon_names()の約2,000件）に対する部分集合であり、
 * ラベルの無いアイコンは名前でのみヒットする。
 *
 * @return array<string,string> icon name => localized label.
 */
function neccospeak_agent_ui_icon_labels() {
	return array(
		'emoji-smile' => __( 'Smile', 'neccospeak' ),
		'emoji-smile-fill' => __( 'Smile (filled)', 'neccospeak' ),
		'emoji-wink' => __( 'Wink', 'neccospeak' ),
		'emoji-heart-eyes' => __( 'Heart eyes', 'neccospeak' ),
		'hand-thumbs-up' => __( 'Thumbs up', 'neccospeak' ),
		'hand-thumbs-up-fill' => __( 'Thumbs up (filled)', 'neccospeak' ),
		'hand-index-thumb' => __( 'Pointing hand', 'neccospeak' ),
		'hand-index-thumb-fill' => __( 'Pointing hand (filled)', 'neccospeak' ),
		'megaphone' => __( 'Megaphone', 'neccospeak' ),
		'bell' => __( 'Bell & notifications', 'neccospeak' ),
		'lightning' => __( 'Lightning', 'neccospeak' ),
		'rocket' => __( 'Rocket', 'neccospeak' ),
		'magic' => __( 'Magic wand', 'neccospeak' ),
		'flower1' => __( 'Flower', 'neccospeak' ),
		'mic' => __( 'Microphone', 'neccospeak' ),
		'mic-fill' => __( 'Microphone (filled)', 'neccospeak' ),
		'mic-mute' => __( 'Microphone off', 'neccospeak' ),
		'mic-mute-fill' => __( 'Microphone off (filled)', 'neccospeak' ),
		'headset' => __( 'Headset', 'neccospeak' ),
		'soundwave' => __( 'Sound wave', 'neccospeak' ),
		'broadcast' => __( 'Broadcast & audio', 'neccospeak' ),
		'volume-up' => __( 'Speaker', 'neccospeak' ),
		'volume-up-fill' => __( 'Speaker (filled)', 'neccospeak' ),
		'telephone' => __( 'Phone', 'neccospeak' ),
		'telephone-fill' => __( 'Phone (filled)', 'neccospeak' ),
		'chat' => __( 'Chat', 'neccospeak' ),
		'chat-dots' => __( 'Chat bubble', 'neccospeak' ),
		'chat-fill' => __( 'Chat (filled)', 'neccospeak' ),
		'envelope' => __( 'Mail', 'neccospeak' ),
		'envelope-fill' => __( 'Mail (filled)', 'neccospeak' ),
		'briefcase-fill' => __( 'Work & services (filled)', 'neccospeak' ),
		'briefcase' => __( 'Work', 'neccospeak' ),
		'building' => __( 'Company & office', 'neccospeak' ),
		'bar-chart-fill' => __( 'Results & charts (filled)', 'neccospeak' ),
		'bar-chart' => __( 'Chart', 'neccospeak' ),
		'tools' => __( 'Tools & services', 'neccospeak' ),
		'gear' => __( 'Settings', 'neccospeak' ),
		'person' => __( 'Staff member', 'neccospeak' ),
		'person-fill' => __( 'Staff member (filled)', 'neccospeak' ),
		'people' => __( 'Team', 'neccospeak' ),
		'person-workspace' => __( 'Recruiting & work', 'neccospeak' ),
		'file-earmark-text' => __( 'Documents', 'neccospeak' ),
		'file-earmark-fill' => __( 'Documents (filled)', 'neccospeak' ),
		'clipboard-check' => __( 'Checklist', 'neccospeak' ),
		'clipboard' => __( 'Clipboard', 'neccospeak' ),
		'pen' => __( 'Writing', 'neccospeak' ),
		'pencil-square' => __( 'Edit & fill in', 'neccospeak' ),
		'house-fill' => __( 'Home (filled)', 'neccospeak' ),
		'house' => __( 'Home', 'neccospeak' ),
		'globe2' => __( 'Website', 'neccospeak' ),
		'phone' => __( 'Smartphone', 'neccospeak' ),
		'arrow-right-circle' => __( 'Guide & next', 'neccospeak' ),
		'currency-yen' => __( 'Pricing (yen)', 'neccospeak' ),
		'coin' => __( 'Pricing', 'neccospeak' ),
		'credit-card' => __( 'Card payment', 'neccospeak' ),
		'robot' => __( 'AI & robot', 'neccospeak' ),
		'laptop' => __( 'Computer & development', 'neccospeak' ),
		'search' => __( 'Search', 'neccospeak' ),
		'info-circle' => __( 'Information & guidance', 'neccospeak' ),
		'question-circle' => __( 'Q&A', 'neccospeak' ),
		'star' => __( 'Favorite', 'neccospeak' ),
		'calendar' => __( 'Booking & schedule', 'neccospeak' ),
		'clock' => __( 'Hours', 'neccospeak' ),
		'list-check' => __( 'Lists', 'neccospeak' ),
	);
}

add_action( 'admin_enqueue_scripts', 'neccospeak_agent_ui_enqueue' );

/**
 * @param string $hook_suffix Current admin screen hook.
 * @return void
 */
function neccospeak_agent_ui_enqueue( $hook_suffix ) {
	if ( ! defined( 'NECCOSPEAK_SETTINGS_SLUG' ) || 'toplevel_page_' . NECCOSPEAK_SETTINGS_SLUG !== $hook_suffix ) {
		return;
	}

	wp_enqueue_media();

	$css_path = NECCOSPEAK_DIR . 'assets/agent-ui/css/admin-agent-settings.css';
	$js_path  = NECCOSPEAK_DIR . 'assets/agent-ui/js/agent-ui.js';
	$bi_path  = NECCOSPEAK_DIR . 'assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css';
	$css_ver  = file_exists( $css_path ) ? filemtime( $css_path ) : NECCOSPEAK_VERSION;
	$js_ver   = file_exists( $js_path ) ? filemtime( $js_path ) : NECCOSPEAK_VERSION;
	$bi_ver   = file_exists( $bi_path ) ? filemtime( $bi_path ) : NECCOSPEAK_VERSION;

	// NS 同梱の bootstrap-icons (widget-frame と共用。dev.37 以降は upstream
	// v1.13.1 のフル版 = 2078 グリフ) — プレビュータブのモック
	// (<i class="bi bi-...">) とランチャーアイコンの描画、および dev.38 の
	// アイコンピッカー（ウィジェット設定／会話フロー）のグリフ描画に必要。
	// この設定画面 (hook ガード済み) 以外では読み込まない。
	//
	// フォントの相対パス (fonts/bootstrap-icons.woff2) はこの CSS 自身の
	// ディレクトリ基準で解決されるため、vendor ディレクトリのまま登録して
	// 問題ない (フレーム側と同一ファイル・同一 URL を共有する)。
	wp_enqueue_style(
		'neccospeak-agent-ui-bootstrap-icons',
		NECCOSPEAK_URL . 'assets/widget-frame/vendor/bootstrap-icons/bootstrap-icons.css',
		array(),
		$bi_ver
	);

	wp_enqueue_style(
		'neccospeak-agent-ui',
		NECCOSPEAK_URL . 'assets/agent-ui/css/admin-agent-settings.css',
		array(),
		$css_ver
	);

	wp_enqueue_script(
		'neccospeak-agent-ui',
		NECCOSPEAK_URL . 'assets/agent-ui/js/agent-ui.js',
		array(),
		$js_ver,
		true
	);

	// dev.38: the ~2078-name icon vocabulary is only shipped to the tabs that
	// actually render a picker (its JSON is ~35KB), never to every tab.
	$icon_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selector, same as neccospeak_agent_ui_render_page().
	$icons    = in_array( $icon_tab, array( 'flow', 'widget' ), true ) ? neccospeak_agent_ui_icon_names() : array();

	wp_localize_script(
		'neccospeak-agent-ui',
		'neccoSpeakAgentUi',
		array(
			'pingUrl'    => esc_url_raw( rest_url( 'neccospeak/v1/ping' ) ),
			'modelsUrl'  => esc_url_raw( rest_url( 'neccospeak/v1/models' ) ),
			'restNonce'  => wp_create_nonce( 'wp_rest' ),
			'icons'      => $icons,
			'iconLabels' => neccospeak_agent_ui_icon_labels(),
			'i18n'       => array(
				'saved'                 => __( 'Saved.', 'neccospeak' ),
				'checking'              => __( 'Checking…', 'neccospeak' ),
				'reachable'             => __( 'REST endpoint is reachable', 'neccospeak' ),
				'unreachable'           => __( 'REST endpoint is unreachable', 'neccospeak' ),
				'modelLoadError'        => __( '(Failed to fetch the model list)', 'neccospeak' ),
				'modelFetchError'       => __( '(API connection error)', 'neccospeak' ),
				// dev.38 — icon picker.
				'previewLoadFailed'     => __( 'Failed to load', 'neccospeak' ),
				'iconPickerTitle'       => __( 'Choose an icon', 'neccospeak' ),
				'iconSearchPlaceholder' => __( 'Search icons…', 'neccospeak' ),
				'iconNoMatch'           => __( 'No icons match your search.', 'neccospeak' ),
				'iconUseTyped'          => __( 'Enter applies the first match — or the exact name you typed.', 'neccospeak' ),
				/* translators: %d: number of icons currently listed in the picker. */
				'iconShowing'           => __( 'Showing the first %d matches — keep typing to narrow the list.', 'neccospeak' ),
				'iconSelect'            => __( 'Select', 'neccospeak' ),
				// dev.67 — 行内クリアの移設先(ピッカーパネル内ボタン)。
				'iconClearChoice'       => __( 'No icon (clear)', 'neccospeak' ),
				// dev.38 — CTA card list + modal.
				'ctaAddTitle'           => __( 'Add CTA', 'neccospeak' ),
				'ctaEditTitle'          => __( 'Edit CTA', 'neccospeak' ),
				'ctaLabelRequired'      => __( 'Enter a button label.', 'neccospeak' ),
				'ctaMaxReached'         => __( 'No more CTAs can be added.', 'neccospeak' ),
				'ctaRemoveConfirm'      => __( 'Remove this CTA?', 'neccospeak' ),
				// 「(Disabled)」の注記と「CTA設定がありません」の空状態は
				// tab-flow.php がサーバ描画済みで、JS は hidden を切り替える
				// だけなので JS 側に文言を渡す必要はない。
				'ctaSlotMain'           => __( 'Main', 'neccospeak' ),
				'ctaSlotSub'            => __( 'Sub', 'neccospeak' ),
				'ctaCondAlways'         => __( 'Always shown', 'neccospeak' ),
				/* translators: %d: seconds before the CTA appears. */
				'ctaCondDelay'          => __( 'Shown after %d seconds', 'neccospeak' ),
				/* translators: %d: number of replies before the CTA appears. */
				'ctaCondReplies'        => __( 'Shown after reply %d', 'neccospeak' ),
				'ctaEmptyValue'         => '—',
			),
		)
	);
}

/* =========================================================================
 * 4. Page render + tab dispatch.
 * ========================================================================= */

/**
 * Replacement for neccospeak_render_settings_page() on the NeccoSpeak
 * top-level admin page (see swap above). Reuses the exact same option
 * sources (neccospeak_get_options() / neccospeak_editing_options()) as the
 * original so every tab file sees the same draft-aware $opts the old UI did.
 *
 * @return void
 */
function neccospeak_agent_ui_render_page() {
	if ( ! current_user_can( 'manage_options' ) ) {
		return;
	}

	$tabs        = neccospeak_agent_ui_tabs();
	$current_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab selector.
	if ( ! array_key_exists( $current_tab, $tabs ) ) {
		$current_tab = 'dashboard';
	}

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

	echo '<div class="wrap ai-agent-dashboard">';
	echo '<h1>';
	echo '<span class="dashicons dashicons-format-chat" style="font-size:24px;margin-right:6px;vertical-align:text-bottom;"></span>';
	echo esc_html__( 'NeccoSpeak', 'neccospeak' );
	if ( defined( 'NECCOSPEAK_VERSION' ) ) {
		// 見出しにはリリース番号だけを出す: プレリリース識別子 (0.3.0-dev.73 の
		// "-dev.73" 部分) は開発中にしか意味を持たず、管理画面に出すと配布版と
		// 見分けがつきにくい。完全なバージョンは定数と readme に残る。
		$version_parts = explode( '-', NECCOSPEAK_VERSION, 2 );
		echo ' <span style="font-size:12px;font-weight:normal;color:#999;margin-left:8px;">v' . esc_html( $version_parts[0] ) . '</span>';
	}
	echo '</h1>';

	echo '<div class="neccospeak-agent-ui-saved-notice">';
	settings_errors( defined( 'NECCOSPEAK_OPTION' ) ? NECCOSPEAK_OPTION : 'neccospeak_options' );

	if ( function_exists( 'neccospeak_render_notice_query_param' ) ) {
		neccospeak_render_notice_query_param();
	}
	echo '</div>';

	echo '<nav class="nav-tab-wrapper ai-agent-tabs">';
	foreach ( $tabs as $slug => $label ) {
		$url     = add_query_arg(
			array(
				'page' => defined( 'NECCOSPEAK_SETTINGS_SLUG' ) ? NECCOSPEAK_SETTINGS_SLUG : 'neccospeak',
				'tab'  => $slug,
			),
			admin_url( 'admin.php' )
		);
		$classes = 'nav-tab' . ( $slug === $current_tab ? ' nav-tab-active' : '' );
		echo '<a href="' . esc_url( $url ) . '" class="' . esc_attr( $classes ) . '">' . esc_html( $label ) . '</a>';
	}
	echo '</nav>';

	// 2026-08 ユーザー決定: APIキー未設定の間は全タブ共通の案内バナーを出す。
	// APIキーはプラグイン全機能の前提のため、新規ユーザーが最初に迷わないよう
	// AI設定タブ(基本設定カードを先頭へ移動済み)へ誘導する。AI設定タブ自身では
	// カードが目の前にあるため表示しない。published(公開値)ではなく編集面の値
	// で判定すると「下書きに入れただけで消える」ため、公開値で判定する。
	if ( 'ai' !== $current_tab && '' === (string) ( isset( $published['openai_api_key'] ) ? $published['openai_api_key'] : '' ) ) {
		$neccospeak_ai_tab_url = add_query_arg(
			array(
				'page' => defined( 'NECCOSPEAK_SETTINGS_SLUG' ) ? NECCOSPEAK_SETTINGS_SLUG : 'neccospeak',
				'tab'  => 'ai',
			),
			admin_url( 'admin.php' )
		);
		echo '<div class="notice notice-warning" style="margin:16px 0 0;"><p>';
		echo esc_html__( 'The OpenAI API key has not been set yet. Every feature of this plugin needs it.', 'neccospeak' );
		echo ' <a href="' . esc_url( $neccospeak_ai_tab_url ) . '">' . esc_html__( 'Set it on the AI settings tab.', 'neccospeak' ) . '</a>';
		echo '</p></div>';
	}

	echo '<div class="ai-agent-tab-content" id="ai-agent-tab-content" data-tab="' . esc_attr( $current_tab ) . '">';

	$renderer = 'neccospeak_agent_ui_tab_' . str_replace( '-', '_', $current_tab );
	if ( function_exists( $renderer ) ) {
		call_user_func( $renderer, $opts );
	} else {
		echo '<div class="ai-agent-section"><p class="description">';
		echo esc_html__( 'This tab is not implemented yet.', 'neccospeak' );
		echo '</p></div>';
	}

	// 2026-08 (_rendered self-report fix, follow-up): neccospeak_agent_ui_form_buffer_control()'s
	// "raw `</form>`, flushed implicitly by the NEXT form_open()" convention (see its
	// docblock) assumes there IS a next form_open() call somewhere later on the page.
	// That's true whenever such a form is followed by another card on the same tab, but
	// two tabs currently render a raw-`</form>`-closed card as the LAST form they open
	// (tab-voice.php's only form; tab-flow.php's Navigation settings card) — and since
	// exactly one $renderer runs per page load (never two tabs on one request), no later
	// form_open() ever arrives to flush it. Left alone, that form's buffer never reaches
	// neccospeak_agent_ui_form_buffer_control( 'flush', ... ) at all, so it never emits its
	// `_rendered[]` self-report and silently falls back to the pre-fix "whole old-tab
	// whitelist" behavior on save — reintroducing this task's exact bug class for whatever
	// OTHER form shares that old-tab bucket (e.g. tab-model.php's 'content' form owns
	// tools_enabled/tools_instructions/rag_* that tab-flow.php's 'content' form never
	// renders; those would get silently reset to defaults every time the Navigation
	// settings card is saved). Force a flush once per page render, right after the tab's
	// renderer returns: a safe no-op when the tab already closed its own last form via
	// neccospeak_agent_ui_form_close() (buffer_control's 'flush' branch returns immediately
	// when nothing is open), and the fix for the still-open case above.
	neccospeak_agent_ui_form_buffer_control( 'flush', '', '' );

	echo '</div>'; // .ai-agent-tab-content

	neccospeak_agent_ui_render_change_summary( $published, $current_tab );

	echo '</div>'; // .wrap
}

/**
 * Sticky change-summary panel (0.3.0-dev.36; a server-rendered port of the
 * production plugin's #228 panel — homepage repo
 * ai-agent-embed/includes/admin-agent-settings.php + its
 * .ai-agent-change-summary CSS): whenever the draft workflow is on and the
 * draft differs from the published options, EVERY tab shows a bottom-sticky
 * bar with publish / discard actions, so saving no longer requires a trip
 * to the Dashboard tab just to publish.
 *
 * Differences from the production original (deliberate):
 *   - Server-rendered from neccospeak_versions_diff_keys() instead of a JS
 *     fetch (NS knows the diff at render time; production asks its SaaS).
 *   - "詳細を見る" links to the Dashboard tab's published-vs-draft table
 *     instead of opening production's publish-preview modal.
 *   - Publish/discard POST to the pre-existing admin-post handlers
 *     (admin-versions.php), with a `neccospeak_return_tab` field so the
 *     redirect comes back to the tab the admin was on.
 *
 * @param array  $published neccospeak_get_options().
 * @param string $current_tab Active agent-ui tab slug.
 * @return void
 */
function neccospeak_agent_ui_render_change_summary( $published, $current_tab ) {
	if ( empty( $published['draft_workflow_enabled'] ) ) {
		return;
	}
	if ( ! function_exists( 'neccospeak_versions_diff_keys' ) || ! function_exists( 'neccospeak_get_draft_options' ) ) {
		return;
	}
	$draft = neccospeak_get_draft_options();
	$diff  = neccospeak_versions_diff_keys( $published, is_array( $draft ) ? $draft : $published );
	if ( empty( $diff ) ) {
		return;
	}

	$shown     = array_slice( $diff, 0, 8 );
	$remaining = count( $diff ) - count( $shown );

	$dashboard_url = add_query_arg(
		array(
			'page' => defined( 'NECCOSPEAK_SETTINGS_SLUG' ) ? NECCOSPEAK_SETTINGS_SLUG : 'neccospeak',
			'tab'  => 'dashboard',
		),
		admin_url( 'admin.php' )
	);
	?>
	<div class="ai-agent-change-summary" aria-live="polite">
		<div class="ai-agent-change-summary__inner">
			<div class="ai-agent-change-summary__main">
				<div class="ai-agent-change-summary__headline">
					<span class="ai-agent-change-summary__pending-label">
						<span class="dashicons dashicons-edit" aria-hidden="true"></span>
						<?php
						printf(
							/* translators: %s: number of pending (unpublished) changes wrapped in <strong> tags. */
							esc_html__( 'Pending changes: %s', 'neccospeak' ),
							'<strong>' . (int) count( $diff ) . '</strong>'
						);
						?>
					</span>
				</div>
				<ul class="ai-agent-change-summary__list">
					<?php foreach ( $shown as $summary_key ) : ?>
						<li><code><?php echo esc_html( $summary_key ); ?></code></li>
					<?php endforeach; ?>
					<?php if ( $remaining > 0 ) : ?>
						<li>
						<?php
						printf(
							/* translators: %d: number of additional changed settings not listed. */
							esc_html__( '…and %d more', 'neccospeak' ),
							(int) $remaining
						);
						?>
						</li>
					<?php endif; ?>
				</ul>
			</div>
			<div class="ai-agent-change-summary__actions">
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
					<input type="hidden" name="action" value="neccospeak_publish" />
					<input type="hidden" name="neccospeak_return_tab" value="<?php echo esc_attr( $current_tab ); ?>" />
					<?php wp_nonce_field( 'neccospeak_versions_publish' ); ?>
					<button type="submit" class="button button-primary"><?php esc_html_e( 'Publish changes', 'neccospeak' ); ?></button>
				</form>
				<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>"
					onsubmit="return window.confirm( <?php echo esc_attr( wp_json_encode( __( 'Discard all unpublished changes? This cannot be undone.', 'neccospeak' ) ) ); ?> );">
					<input type="hidden" name="action" value="neccospeak_discard" />
					<input type="hidden" name="neccospeak_return_tab" value="<?php echo esc_attr( $current_tab ); ?>" />
					<?php wp_nonce_field( 'neccospeak_versions_discard' ); ?>
					<button type="submit" class="button"><?php esc_html_e( 'Discard changes', 'neccospeak' ); ?></button>
				</form>
				<a class="button button-link" href="<?php echo esc_url( $dashboard_url ); ?>"><?php esc_html_e( 'View details', 'neccospeak' ); ?></a>
			</div>
		</div>
	</div>
	<?php
}

/* =========================================================================
 * 5. Per-tab-file helpers.
 *
 * Every "card" a tab-{slug}.php file renders that needs to save something
 * is a self-contained <form action="options.php">. Multiple independent
 * forms per page (never nested) is deliberate: this new top-tab layout
 * groups fields differently than includes/settings.php's OLD tab registry
 * (neccospeak_tab_fields()), so a single top-tab here (e.g. "AI Settings")
 * commonly spans more than one OLD tab's save-whitelist (e.g. 'general' +
 * 'prompt') and each needs its own settings_fields() + hidden "_tab" value
 * to land in the right neccospeak_sanitize_options() bucket.
 * ========================================================================= */

/**
 * Open a save-form whose hidden "_tab" targets $old_tab_slug's whitelist in
 * neccospeak_tab_fields() (includes/settings.php, unmodified).
 *
 * @param string $old_tab_slug One of neccospeak_tab_fields()'s existing keys
 *                              (e.g. 'general', 'appearance', 'prompt').
 * @return void
 */
function neccospeak_agent_ui_form_open( $old_tab_slug ) {
	$option = defined( 'NECCOSPEAK_OPTION' ) ? NECCOSPEAK_OPTION : 'neccospeak_options';
	echo '<form method="post" action="' . esc_url( admin_url( 'options.php' ) ) . '" class="neccospeak-agent-ui-form">';
	settings_fields( 'neccospeak_settings_group' );
	echo '<input type="hidden" name="' . esc_attr( $option ) . '[_tab]" value="' . esc_attr( $old_tab_slug ) . '" />';
	neccospeak_agent_ui_form_buffer_control( 'open', $option );
}

/**
 * Shared state + flush logic behind neccospeak_agent_ui_form_open()'s /
 * _form_close()'s "self-report which options[KEY] fields this form actually
 * rendered" mechanism (consumed by includes/settings.php's
 * neccospeak_sanitize_options() -- see its `$input['_rendered']` handling).
 *
 * Not part of the public API; called only by the two functions below. Exactly
 * one agent-ui form buffer is ever open at a time -- the tab-*.php files
 * render forms sequentially, never nested -- so a single flag is enough; no
 * stack is needed. State lives in `static` locals (not globals) so nothing
 * outside these three functions can desync it.
 *
 * Some cards intentionally close their <form> with a raw `</form>` instead of
 * calling neccospeak_agent_ui_form_close() (e.g. tab-ai.php's tone/persona/
 * rules cards, tab-flow.php's widget-behavior card -- their own docblocks
 * explain why: a submit button already lives in a section header, and a
 * second one from _form_close() would double it up). Nothing therefore ever
 * asks this buffer to close for those forms. The 'open' branch below treats a
 * still-open buffer as an implicit "close the previous form now" signal: it
 * flushes it (scanned, so those forms still get their `_rendered` markers)
 * with no synthesized close markup, since the buffered HTML already contains
 * its own submit button and its own literal `</form>`. This is what keeps
 * form_open()-without-form_close() from swallowing the rest of the page.
 *
 * @param string $action      'open' or 'flush'.
 * @param string $option_name Option base name (e.g. 'neccospeak_options').
 *                              Only meaningful for 'open'; ignored for
 *                              'flush', which reuses whatever was recorded
 *                              when the buffer now being flushed was opened.
 * @param string $append_html For 'flush' only: HTML to print after the
 *                              scanned buffer content -- form_close()'s
 *                              submit button + `</form>`, or '' for the
 *                              implicit flush described above.
 * @return void
 */
function neccospeak_agent_ui_form_buffer_control( $action, $option_name = '', $append_html = '' ) {
	static $open   = false;
	static $option = 'neccospeak_options';

	if ( 'flush' === $action ) {
		if ( ! $open ) {
			// Defensive: a stray/unmatched close with no buffer of ours open.
			// Never call ob_get_clean() here -- it could pop a buffer this
			// code did not start (WordPress core, a caching plugin, etc.).
			if ( '' !== $append_html ) {
				echo $append_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- caller-built trusted markup (submit button + closing form tag).
			}
			return;
		}

		$open = false;
		$html = ob_get_clean();
		$html = ( false !== $html ) ? $html : ''; // Never let a false confuse the echo below into swallowing content.

		$keys    = array();
		$pattern = '/name=["\']' . preg_quote( $option, '/' ) . '\[([A-Za-z0-9_]+)\]/';
		if ( preg_match_all( $pattern, $html, $matches ) ) {
			$keys = array_unique( $matches[1] );
		}
		foreach ( $keys as $key ) {
			if ( '_tab' === $key || '_rendered' === $key ) {
				continue; // Never self-report the plumbing fields as "rendered content" keys.
			}
			echo '<input type="hidden" name="' . esc_attr( $option ) . '[_rendered][]" value="' . esc_attr( $key ) . '" />';
		}

		echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- our own previously-escaped buffered markup, echoed back unchanged.

		if ( '' !== $append_html ) {
			echo $append_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- caller-built trusted markup (submit button + closing form tag).
		}
		return;
	}

	// 'open'.
	if ( $open ) {
		// A previous form_open() never reached a matching close -- flush it
		// now (see docblock) before starting the new buffer.
		neccospeak_agent_ui_form_buffer_control( 'flush', '', '' );
	}
	$option = ( '' !== $option_name ) ? $option_name : $option;
	$open   = true;
	ob_start();
}

/**
 * 0.3.0-dev.38 (会話フロー本番パリティ フェーズ2 §1) — one icon-picker field:
 * a glyph button + the hidden input that actually carries the value.
 *
 * Shared part deliberately (the brief's "共通部品"): the conversation-flow
 * tab uses it for both a quick-action row's icon and the CTA modal's icon,
 * and the widget tab can adopt it without a second implementation.
 *
 * Markup contract with assets/agent-ui/js/agent-ui.js's initIconPickers():
 *   [data-neccospeak-icon-field]  wrapper (the picker scopes its lookups here)
 *     [data-neccospeak-icon-btn]  the button that opens the picker; its inner
 *                                 HTML is swapped for the chosen glyph
 *     [data-neccospeak-icon-value] the hidden input (the POSTed value)
 *     [data-neccospeak-icon-name] text echo of the raw name, so the value is
 *                                 readable (and copyable) without a mouse
 *     [data-neccospeak-icon-clear] reset-to-none button
 *
 * Accessibility note: the value input is hidden, so the ONLY way to type a
 * name is the picker's own search box, which doubles as free-text entry
 * (Enter applies whatever was typed when it looks like a Bootstrap Icons
 * name). Without JS the field is display-only — the stored value still
 * round-trips through the hidden input untouched, so a no-JS save can never
 * blank an icon.
 *
 * @param array $args {
 *     @type string $name     Input name attribute. '' renders no name (modal-only
 *                            editing UI whose value must never be POSTed).
 *     @type string $id       Input id attribute. '' renders no id.
 *     @type string $value    Current icon value (NS enum member or bi- suffix).
 *     @type bool   $disabled Render the input disabled (repeater prototype rows).
 *     @type string $class    Extra class(es) for the wrapper.
 * }
 * @return void
 */
function neccospeak_agent_ui_icon_field( $args ) {
	$args = is_array( $args ) ? $args : array();
	$name = isset( $args['name'] ) ? (string) $args['name'] : '';
	$id   = isset( $args['id'] ) ? (string) $args['id'] : '';
	$val  = isset( $args['value'] ) ? (string) $args['value'] : '';
	$dis  = ! empty( $args['disabled'] );
	$cls  = isset( $args['class'] ) ? (string) $args['class'] : '';

	// 'none' is the NS enum's explicit "no icon" member; show it as empty in
	// the UI (the hidden input still POSTs whatever was stored).
	$display = ( 'none' === $val ) ? '' : $val;
	$glyph   = ( '' !== $display && function_exists( 'neccospeak_frame_chip_icon_bi' ) )
		? neccospeak_frame_chip_icon_bi( $display )
		: $display;
	?>
	<span class="<?php echo esc_attr( trim( 'neccospeak-icon-field ' . $cls ) ); ?>" data-neccospeak-icon-field>
		<button type="button" class="button neccospeak-icon-btn" data-neccospeak-icon-btn
			title="<?php echo esc_attr( '' !== $display ? $display : __( 'Choose an icon', 'neccospeak' ) ); /* dev.67: 名前のインライン表示を隠したため、選択中の名前は title で読める */ ?>"
			aria-label="<?php echo esc_attr__( 'Choose an icon', 'neccospeak' ); ?>">
			<?php if ( '' !== $glyph ) : ?>
				<i class="bi bi-<?php echo esc_attr( $glyph ); ?>" aria-hidden="true"></i>
			<?php else : ?>
				<span class="neccospeak-icon-btn__placeholder"><?php echo esc_html__( 'Select', 'neccospeak' ); ?></span>
			<?php endif; ?>
		</button>
		<input type="hidden" class="neccospeak-icon-value" data-neccospeak-icon-value
			<?php echo ( '' !== $name ) ? ' name="' . esc_attr( $name ) . '"' : ''; ?>
			<?php echo ( '' !== $id ) ? ' id="' . esc_attr( $id ) . '"' : ''; ?>
			value="<?php echo esc_attr( $val ); ?>"<?php echo $dis ? ' disabled' : ''; ?> />
		<span class="neccospeak-icon-name" data-neccospeak-icon-name><?php echo esc_html( $display ); ?></span>
		<button type="button" class="button-link neccospeak-icon-clear" data-neccospeak-icon-clear
			<?php echo ( '' === $display ) ? ' hidden' : ''; ?>><?php echo esc_html__( 'Clear', 'neccospeak' ); ?></button>
	</span>
	<?php
}

/**
 * Close a form opened with neccospeak_agent_ui_form_open(), printing a
 * production-styled primary save button.
 *
 * @param string $label Button text. Defaults to "Save".
 * @return void
 */
function neccospeak_agent_ui_form_close( $label = '' ) {
	if ( '' === $label ) {
		$label = __( 'Save', 'neccospeak' );
	}
	$close_markup  = '<p class="submit" style="margin-top:12px;"><button type="submit" class="button button-primary">' . esc_html( $label ) . '</button></p>';
	$close_markup .= '</form>';
	neccospeak_agent_ui_form_buffer_control( 'flush', '', $close_markup );
}

/* =========================================================================
 * 6. Tab bodies (one file per slug; file_exists()-guarded so a partial
 *    checkout -- tab files landing from a parallel work stream -- never
 *    fatals the whole admin screen, matching the file_exists() discipline
 *    neccospeak.php and includes/settings.php already use throughout).
 * ========================================================================= */

foreach ( array_keys( neccospeak_agent_ui_tabs() ) as $neccospeak_agent_ui_tab_slug ) {
	$neccospeak_agent_ui_tab_file = NECCOSPEAK_DIR . 'includes/agent-ui-tabs/tab-' . $neccospeak_agent_ui_tab_slug . '.php';
	if ( file_exists( $neccospeak_agent_ui_tab_file ) ) {
		require_once $neccospeak_agent_ui_tab_file;
	}
}
unset( $neccospeak_agent_ui_tab_slug, $neccospeak_agent_ui_tab_file );
