<?php
if (!defined('ABSPATH')) {
	exit;
}

/**
 * Class Podcastmaker_Script_Generator
 *
 * スクリプト生成機能 (カスタム投稿タイプ) を処理するクラス。
 */
class Podcastmaker_Script_Generator
{

	/**
	 * コンストラクタ的な役割 (initフックで呼ばれる)。
	 * カスタム投稿タイプを登録する。
	 */
	public function register_cpt()
	{
		$labels = array(
			'name' => _x('Talk Scripts', 'Post Type General Name', 'quikvox-ai'),
			'singular_name' => _x('Talk Script', 'Post Type Singular Name', 'quikvox-ai'),
			'menu_name' => __('Talk Scripts', 'quikvox-ai'),
			'name_admin_bar' => __('Talk Script', 'quikvox-ai'),
			'archives' => __('Talk Script Archives', 'quikvox-ai'),
			'attributes' => __('Talk Script Attributes', 'quikvox-ai'),
			'parent_item_colon' => __('Parent Talk Script:', 'quikvox-ai'),
			'all_items' => __('Talk Scripts', 'quikvox-ai'),
			'add_new_item' => __('Add New Talk Script', 'quikvox-ai'),
			'add_new' => __('Add New', 'quikvox-ai'),
			'new_item' => __('New Talk Script', 'quikvox-ai'),
			'edit_item' => __('Edit Talk Script', 'quikvox-ai'),
			'update_item' => __('Update Talk Script', 'quikvox-ai'),
			'view_item' => __('View Talk Script', 'quikvox-ai'),
			'view_items' => __('View Talk Scripts', 'quikvox-ai'),
			'search_items' => __('Search Talk Script', 'quikvox-ai'),
			'not_found' => __('Not found', 'quikvox-ai'),
			'not_found_in_trash' => __('Not found in Trash', 'quikvox-ai'),
			'featured_image' => __('Featured Image', 'quikvox-ai'),
			'set_featured_image' => __('Set featured image', 'quikvox-ai'),
			'remove_featured_image' => __('Remove featured image', 'quikvox-ai'),
			'use_featured_image' => __('Use as featured image', 'quikvox-ai'),
			'insert_into_item' => __('Insert into talk script', 'quikvox-ai'),
			'uploaded_to_this_item' => __('Uploaded to this talk script', 'quikvox-ai'),
			'items_list' => __('Talk scripts list', 'quikvox-ai'),
			'items_list_navigation' => __('Talk scripts list navigation', 'quikvox-ai'),
			'filter_items_list' => __('Filter talk scripts list', 'quikvox-ai'),
		);
		$args = array(
			'label' => __('Script', 'quikvox-ai'),
			'description' => __('Podcast Scripts', 'quikvox-ai'),
			'labels' => $labels,
			'supports' => array('title', 'editor', 'custom-fields', 'revisions'),
			'hierarchical' => false,
			'public' => false, // Not visible on frontend
			'show_ui' => true,
			'show_in_menu' => 'Podcastmaker', // Show under QuikVox AI parent menu
			'menu_position' => 20,
			'show_in_admin_bar' => true,
			'show_in_nav_menus' => false,
			'can_export' => true,
			'has_archive' => false,
			'exclude_from_search' => true,
			'publicly_queryable' => false,
			'capability_type' => 'post',
			'show_in_rest' => true, // Enable Gutenberg Editor
		);
		register_post_type('podcastmaker_script', $args);

		// 状態保存用のメタフィールドを登録
		// $meta_args: メタフィールドの共通設定配列
		$meta_args = array(
			'show_in_rest' => true,
			'single' => true,
			'type' => 'string',
			'auth_callback' => array($this, 'can_current_user_edit_script_meta'),
		);

		register_post_meta('podcastmaker_script', '_Podcastmaker_source_post_id', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_script_mode', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_script_language', array(
			'show_in_rest' => true,
			'single' => true,
			'type' => 'string',
			'sanitize_callback' => array($this, 'sanitize_script_language_meta'),
			'auth_callback' => array($this, 'can_current_user_edit_script_meta'),
		));
		register_post_meta('podcastmaker_script', '_Podcastmaker_script_length', array(
			'show_in_rest' => true,
			'single' => true,
			'type' => 'string',
			'sanitize_callback' => array($this, 'sanitize_script_length_meta'),
			'auth_callback' => array($this, 'can_current_user_edit_script_meta'),
		));
		register_post_meta('podcastmaker_script', '_Podcastmaker_voice_direction', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_voice_id', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_voice_summary_label', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_multi_speaker_map', $meta_args);
		register_post_meta('podcastmaker_script', '_Podcastmaker_audio_url', $meta_args);

		register_post_meta('podcastmaker_script', '_Podcastmaker_audio_id', array(
			'show_in_rest' => true,
			'single' => true,
			'type' => 'integer',
			'auth_callback' => array($this, 'can_current_user_edit_script_meta'),
		));

	register_post_meta('podcastmaker_script', '_Podcastmaker_gemini_tts_model', $meta_args);
	}

	/**
	 * Normalize the requested script language for storage and generation.
	 *
	 * @param string $language The requested language code.
	 * @param string $fallback The fallback language code.
	 * @return string
	 */
	private function normalize_script_language_code($language, $fallback = 'en')
	{
		$normalized_language = strtolower(trim((string) $language));
		if ($normalized_language === '') {
			$normalized_language = strtolower(trim((string) $fallback));
		}

		return $normalized_language !== '' ? $normalized_language : 'en';
	}

	/**
	 * Sanitize the stored script language meta value.
	 *
	 * @param mixed $value Raw meta value.
	 * @return string
	 */
	public function sanitize_script_language_meta($value)
	{
		return $this->normalize_script_language_code($value, 'en');
	}

	/**
	 * Sanitize the stored script length meta value.
	 *
	 * @param mixed $value Raw meta value.
	 * @return string
	 */
	public function sanitize_script_length_meta($value)
	{
		$normalized_length = strtolower(trim((string) $value));
		$allowed_lengths = array('30sec', '60sec', '180sec');

		return in_array($normalized_length, $allowed_lengths, true) ? $normalized_length : '30sec';
	}

	/**
	 * コンストラクタ。
	 * フックやAJAXアクションを登録する。
	 */
	public function __construct()
	{
		add_action('init', array($this, 'register_cpt'));
		add_action('enqueue_block_editor_assets', array($this, 'enqueue_editor_assets'));

		// AJAX Actions (AJAXアクション)
		add_action('wp_ajax_Podcastmaker_generate_script', array($this, 'ajax_generate_script'));
		add_action('wp_ajax_Podcastmaker_generate_voice', array($this, 'ajax_generate_voice'));
		add_action('wp_ajax_Podcastmaker_prepare_multi_speaker_voice', array($this, 'ajax_prepare_multi_speaker_voice'));
		add_action('wp_ajax_Podcastmaker_generate_multi_speaker_chunk', array($this, 'ajax_generate_multi_speaker_chunk'));
		add_action('wp_ajax_Podcastmaker_finalize_multi_speaker_voice', array($this, 'ajax_finalize_multi_speaker_voice'));
		add_action('wp_ajax_Podcastmaker_insert_audio_to_post', array($this, 'ajax_insert_audio_to_post'));
		add_action('wp_ajax_Podcastmaker_translate_prompt', array($this, 'ajax_translate_prompt'));
		add_action('wp_ajax_Podcastmaker_apply_smart_tone', array($this, 'ajax_apply_smart_tone'));

		// List view customizations (一覧画面のカスタマイズ)
		add_filter('manage_podcastmaker_script_posts_columns', array($this, 'set_custom_edit_columns'));
		add_action('manage_podcastmaker_script_posts_custom_column', array($this, 'custom_column'), 10, 2);
		add_filter('manage_edit-podcastmaker_script_sortable_columns', array($this, 'set_custom_sortable_columns'));
		add_action('save_post_podcastmaker_script', array($this, 'assign_sequential_id'), 10, 2);
		add_filter('post_row_actions', array($this, 'modify_title_column'), 10, 2);

		// Admin assets (CSS/JS) via wp_enqueue
		add_action('admin_enqueue_scripts', array($this, 'enqueue_admin_assets'));
	}

	/**
	 * Check whether the current user can edit the requested script post.
	 *
	 * @param int $post_id The post ID being edited.
	 * @return bool
	 */
	private function can_current_user_edit_script_post($post_id): bool
	{
		$normalized_post_id = absint($post_id);
		return $normalized_post_id > 0 && current_user_can('edit_post', $normalized_post_id);
	}

	/**
	 * Check whether the current user can edit registered script meta.
	 *
	 * @param mixed  $allowed Unused default permission value.
	 * @param string $meta_key The meta key being checked.
	 * @param int    $post_id The post ID being edited.
	 * @return bool
	 */
	public function can_current_user_edit_script_meta($allowed, $meta_key, $post_id): bool
	{
		return $this->can_current_user_edit_script_post($post_id);
	}

	/**
	 * 新しい台本投稿に連番IDを割り当てる。
	 */
	public function assign_sequential_id($post_id, $post)
	{
		if (!$post || (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE))
			return;
		if ('podcastmaker_script' !== $post->post_type)
			return;

		$index_id = get_post_meta($post_id, '_Podcastmaker_index_id', true);
		if (empty($index_id)) {
			$next_id = (int)get_option('Podcastmaker_script_next_id', 1);
			update_post_meta($post_id, '_Podcastmaker_index_id', $next_id);
			update_option('Podcastmaker_script_next_id', $next_id + 1);
		}
	}

	/**
	 * コンテンツプレビューの配置とIDカラムの幅を修正するためのインラインCSS。
	 */
	/**
	 * 管理画面アセット（CSS/JS）を wp_enqueue で読み込む。
	 * admin_enqueue_scripts フックで実行。
	 *
	 * @param string $hook 現在の管理画面フックサフィックス。
	 */
	public function enqueue_admin_assets($hook)
	{
		$screen = get_current_screen();
		if (!$screen) {
			return;
		}

		if ('edit-podcastmaker_script' !== $screen->id
		&& 'settings_page_Podcastmaker' !== $screen->id
		&& 'podcastmaker_page_Podcastmaker_settings' !== $screen->id
		&& 'Podcastmaker_page_Podcastmaker-settings' !== $screen->id
		&& 'podcastmaker_page_Podcastmaker-settings' !== $screen->id
		&& 'toplevel_page_Podcastmaker' !== $screen->id) {
			return;
		}

		$plugin_url = plugin_dir_url(dirname(__FILE__));
		$version = defined('PODCASTMAKER_VERSION') ? PODCASTMAKER_VERSION : time();

		wp_enqueue_style(
			'podcastmaker-admin',
			$plugin_url . 'assets/css/admin.css',
			array(),
			$version
		);

		wp_enqueue_script(
			'podcastmaker-admin',
			$plugin_url . 'assets/js/admin.js',
			array(),
			$version,
			true
		);
		if (function_exists('wp_set_script_translations')) {
			wp_set_script_translations(
				'podcastmaker-admin',
				'quikvox-ai',
				plugin_dir_path(dirname(__FILE__)) . 'languages'
			);
		}

		// js渡しデータを構築
		$localize_data = array(
			'ajaxUrl' => admin_url('admin-ajax.php'),
			'translateNonce' => wp_create_nonce('Podcastmaker_translate_nonce'),
			'titleData' => array(),
		);

		// 一覧画面：行ごとのタイトル置換データを渡す
		if ('edit-podcastmaker_script' === $screen->id) {
			$query = new WP_Query(array(
				'post_type' => 'podcastmaker_script',
				'posts_per_page' => 50,
				'fields' => 'ids',
			));
			$title_data = array();
			foreach ($query->posts as $script_id) {
				$source_id = get_post_meta($script_id, '_Podcastmaker_source_post_id', true);
				if (empty($source_id))
					continue;
				$source_post = get_post($source_id);
				if (!$source_post)
					continue;

				$categories = get_the_category($source_id);
				$cat_names = '';
				if (!empty($categories)) {
					$first_cats = array_slice($categories, 0, 2);
					$cat_names = implode(', ', array_map(function ($cat) {
						return $cat->name;
					}, $first_cats));
					if (count($categories) > 2) {
						$cat_names .= ' +' . (count($categories) - 2);
					}
				}

				$title_data[] = array(
					'postId' => (int)$script_id,
					'title' => $source_post->post_title,
					'date' => get_the_date('Y/m/d', $source_id),
					'categories' => $cat_names,
				);
			}
			$localize_data['titleData'] = $title_data;
		}

		wp_localize_script('podcastmaker-admin', 'PodcastmakerAdminListData', $localize_data);
	}

	/**
	 * カラムをソート可能にする。
	 */
	public function set_custom_sortable_columns($columns)
	{
		$columns['index_id'] = 'index_id';
		return $columns;
	}

	/**
	 * Generate podcast script text from a source post.
	 *
	 * @param int   $source_post_id
	 * @param array $options {
	 *   string $prompt_id       Required.
	 *   string $script_language e.g. 'ja', 'en'. Default: prompt's language.
	 *   string $script_length   '30sec'|'60sec'|'180sec'. Default: '30sec'.
	 *   string $model_id        Optional model override.
	 * }
	 * @return string|WP_Error Generated script text on success, WP_Error on failure.
	 */
	public function generate_script_content_for_post($source_post_id, $options)
	{
		$prompt_id         = isset($options['prompt_id'])       ? sanitize_text_field($options['prompt_id'])       : '';
		$script_language   = isset($options['script_language']) ? sanitize_text_field($options['script_language']) : '';
		$script_length     = isset($options['script_length'])   ? sanitize_text_field($options['script_length'])   : '30sec';
		$model_id_override = isset($options['model_id'])        ? sanitize_text_field($options['model_id'])        : '';

		if (empty($prompt_id) || empty($source_post_id)) {
			return new WP_Error('missing_params', 'Missing required parameters.');
		}

		// 1. 元記事のコンテンツを取得
		// $source_post: 元記事の投稿オブジェクト
		$source_post = get_post($source_post_id);
		if (!$source_post) {
			return new WP_Error('post_not_found', 'Source post not found.');
		}

		// コンテンツをクリーニング (HTML、ショートコードを削除)
		// $content_clean: クリーニング済みのコンテンツ
		$content_clean = wp_strip_all_tags(strip_shortcodes($source_post->post_content));

		// 2. プロンプト指示を取得
		if (!class_exists('Podcastmaker_Prompt_Sets')) {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-prompt-sets.php';
		}
		// $prompt_manager: プロンプトセット管理クラスのインスタンス
		$prompt_manager = new Podcastmaker_Prompt_Sets();
		// $prompt_set: 取得したプロンプトセット配列
		$prompt_set = $prompt_manager->get($prompt_id);

		if (!$prompt_set) {
			return new WP_Error('prompt_not_found', 'Prompt Set not found.');
		}

		// $prompt_text: プロンプト本文
		$prompt_text = $prompt_set['prompt'];
		// $prompt_lang: プロンプトの言語設定
		$prompt_lang         = isset($prompt_set['language'])     ? $prompt_set['language']     : 'en';
		$prompt_speaker_type = isset($prompt_set['speaker_type']) && $prompt_set['speaker_type'] === 'multi' ? 'multi' : 'single';
		$script_language     = $this->normalize_script_language_code($script_language, $prompt_lang);
		$prompt_text         = $this->expand_prompt_with_base_prompt($prompt_manager, $prompt_text, $prompt_lang, $prompt_speaker_type);

		// AI指示のために言語コードをフルネームにマッピング
		// $lang_names: 言語コードと名前の対応配列
		$lang_names = array(
			'ja'    => 'Japanese',
			'en'    => 'English',
			'zh-cn' => 'Chinese (Simplified)',
			'zh-tw' => 'Chinese (Traditional)',
			'ko'    => 'Korean',
		);
		// $output_lang_name: 出力言語の英語名
		$output_lang_name = isset($lang_names[$script_language]) ? $lang_names[$script_language] : 'English';

		// プロンプト + コンテンツ + 言語強制を結合
		// 元記事の言語に関わらず、出力言語を優先するようAIに明確に指示する
		// $final_prompt: Geminiに送信する最終的なプロンプト
		$final_prompt  = "INSTRUCTION:\n" . $prompt_text . "\n\n";
		$final_prompt .= "DURATION REQUIREMENT:\n" . $this->build_script_length_instruction($script_language, $prompt_speaker_type, $script_length) . "\n\n";
		$final_prompt .= "OUTPUT LANGUAGE REQUIREMENT:\nAlways generate the final response in {$output_lang_name}. This is critical regardless of the source article's language.\n\n";
		$final_prompt .= "--- Source Article Start ---\n" . $content_clean . "\n--- Source Article End ---";

		// 3. API Providerに基づいてAPIを呼び出す
		$provider = get_option('podcastmaker_api_provider', 'google_ai_studio');

		// V-08: モデルIDのフォーマット検証 (英数字・ハイフン・ドットのみ許可)
		if (!empty($model_id_override) && !preg_match('/^[a-zA-Z0-9\-\.]+$/', $model_id_override)) {
			$model_id_override = '';
		}

		if ($provider === 'vertex_ai') {
			// --- Vertex AI ---
			if (!class_exists('Podcastmaker_API_Vertex')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
			}
			$vertex_json     = get_option('podcastmaker_vertex_auth_json', '');
			$vertex_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_location', 'global'));

			if (empty($vertex_json)) {
				return new WP_Error('vertex_not_configured', 'Vertex AI: Service Account JSON is not configured. Check Settings > Vertex AI tab.');
			}

			$model = !empty($model_id_override) ? $model_id_override : get_option('podcastmaker_vertex_model', 'gemini-2.5-flash');
			$model = $this->normalize_text_model($model, 'gemini-2.5-flash');
			// ハードフォールバック: モデルが空にならないようにする
			if (empty($model)) {
				$model = 'gemini-2.5-flash';
			}
			$resolved_location = $this->resolve_vertex_location_for_model($model, 'text', $vertex_location, 'us-central1');
			if (is_wp_error($resolved_location)) {
				return $resolved_location;
			}
			$api = new Podcastmaker_API_Vertex($vertex_json, $resolved_location);

		} else {
			// --- Google AI Studio ---
			if (!class_exists('Podcastmaker_API_Gemini')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
			}
			$api_key = get_option('Podcastmaker_gemini_api_key');

			if (empty($api_key)) {
				return new WP_Error('api_key_missing', 'Gemini API Key is missing in settings.');
			}

			$model = !empty($model_id_override) ? $model_id_override : get_option('Podcastmaker_gemini_model', 'gemini-2.5-flash');
			$model = $this->normalize_text_model($model, 'gemini-2.5-flash');
			// ハードフォールバック: モデルが空にならないようにする
			if (empty($model)) {
				$model = 'gemini-2.5-flash';
			}
			$api = new Podcastmaker_API_Gemini($api_key);
		}

		$result = $api->generate_content($model, $final_prompt);
		if (is_wp_error($result)) {
			return $result;
		}

		return $result;
	}

	/**
	 * AJAXハンドラ: Gemini経由でスクリプトを生成する。
	 */
	public function ajax_generate_script()
	{
		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$prompt_id      = isset($_POST['prompt_id'])      ? sanitize_text_field(wp_unslash($_POST['prompt_id']))      : '';
		$source_post_id = isset($_POST['source_post_id']) ? absint($_POST['source_post_id'])                          : 0;
		$post_id        = isset($_POST['post_id'])         ? absint($_POST['post_id'])                                  : 0;

		if (empty($prompt_id) || empty($source_post_id)) {
			wp_send_json_error(array('message' => 'Missing required parameters.'));
		}

		if (!$this->can_current_user_edit_script_post($post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		// V-05: 対象投稿を閲覧する権限があるか確認する (IDOR対策)
		if (!current_user_can('read_post', $source_post_id)) {
			wp_send_json_error(array('message' => 'Permission denied for this post.'));
		}

		$result = $this->generate_script_content_for_post($source_post_id, array(
			'prompt_id'       => $prompt_id,
			'script_language' => isset($_POST['script_language']) ? sanitize_text_field(wp_unslash($_POST['script_language'])) : '',
			'script_length'   => isset($_POST['script_length'])   ? sanitize_text_field(wp_unslash($_POST['script_length']))   : '30sec',
			'model_id'        => isset($_POST['model_id'])        ? sanitize_text_field(wp_unslash($_POST['model_id']))        : '',
		));

		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
		}

		// Success
		wp_send_json_success(array('script' => $result));
	}

	/**
	 * AJAXハンドラ: Gemini経由で音声を生成する。
	 */
	/**
	 * Generate audio from script content and save to media library.
	 *
	 * @param int    $script_post_id  The podcastmaker_script post ID.
	 * @param string $script_content  The script text to convert.
	 * @param array  $options {
	 *   string $tts_model       TTS model ID. Default: Settings value.
	 *   string $script_mode     'single'|'multi'. Default: 'single'.
	 *   string $voice_id        Voice ID for single speaker.
	 *   string $voice_b_id      Second speaker voice ID for multi mode.
	 *   string $voice_direction Optional voice direction text.
	 * }
	 * @return array|WP_Error {
	 *   string $audio_url
	 *   int    $audio_id
	 *   string $voice_summary_label
	 *   string $speaker_map_json
	 * }
	 */
	public function generate_and_save_audio($script_post_id, $script_content, $options)
	{
		$script_mode     = isset($options['script_mode']) ? $this->normalize_requested_script_mode($options['script_mode']) : 'single';
		$voice_id        = isset($options['voice_id']) ? $options['voice_id'] : '';
		$voice_b_id      = isset($options['voice_b_id']) ? $options['voice_b_id'] : '';
		$voice_direction = isset($options['voice_direction']) ? $options['voice_direction'] : '';
		$tts_model       = isset($options['tts_model']) && $options['tts_model'] !== ''
			? $options['tts_model']
			: get_option('Podcastmaker_gemini_tts_model', 'gemini-2.5-flash-preview-tts');

		// Use provided speaker_map (AJAX path) or defer building it until after speaker detection (bulk path).
		$speaker_map = isset($options['speaker_map']) && is_array($options['speaker_map'])
			? $options['speaker_map']
			: array();

		if (empty($script_content) || mb_strlen($script_content) > 50000) {
			return new WP_Error('invalid_content', 'Invalid or too long script content.');
		}
		if ($script_mode === 'single' && empty($voice_id)) {
			return new WP_Error('missing_voice_id', 'Voice ID is required.');
		}
		if ($script_mode === 'multi' && !Podcastmaker_License::is_pro()) {
			return new WP_Error('not_pro', 'Multi-speaker voice generation is available for Pro and Business plans only.');
		}
		if ($script_mode === 'multi' && count($speaker_map) === 0) {
			return new WP_Error('missing_speaker_map', 'Speaker voice mapping is required for multi-speaker generation.');
		}

		$provider            = $this->get_effective_tts_provider($script_mode);
		$audio_data          = null;
		$voice_summary_label = '';
		$stored_voice_id     = '';
		$stored_voice_label  = '';
		$stored_speaker_map_json = '';
		$speaker_count       = 1;

		if ($script_mode === 'multi') {
			$turns = $this->parse_multi_speaker_turns_with_parser_model($script_content, $provider);
			if (is_wp_error($turns)) {
				return $turns;
			}

			$detected_speakers = $this->get_unique_speakers_from_turns($turns);
			if (count($detected_speakers) < 2) {
				return new WP_Error('too_few_speakers', 'Exactly two speakers are required for multi-speaker generation.');
			}
			if (count($detected_speakers) > 2) {
				return new WP_Error('too_many_speakers', 'Gemini TTS supports exactly two speakers. Three or more speakers were detected.');
			}

			// Bulk path: speaker_map not provided upfront — build it from voice_id/voice_b_id by detection order.
			if (empty($speaker_map) && !empty($voice_id)) {
				$ordered_voices = array_values(array_filter(array($voice_id, $voice_b_id)));
				foreach (array_values($detected_speakers) as $i => $speaker_name) {
					$normalized = $this->normalize_speaker_name_key($speaker_name);
					if ($normalized !== '') {
						$speaker_map[$normalized] = isset($ordered_voices[$i]) ? $ordered_voices[$i] : '';
					}
				}
			}

			foreach ($detected_speakers as $speaker_name) {
				$normalized_speaker_name = $this->normalize_speaker_name_key($speaker_name);
				if ($normalized_speaker_name === '' || !isset($speaker_map[$normalized_speaker_name]) || $speaker_map[$normalized_speaker_name] === '') {
					return new WP_Error('missing_speaker_voice', sprintf('A voice is missing for speaker "%s".', $speaker_name));
				}
			}

			$normalized_detected_speakers = array_map(array($this, 'normalize_speaker_name_key'), $detected_speakers);
			$speaker_map = array_intersect_key($speaker_map, array_flip($normalized_detected_speakers));
			$generation_speaker_map = $this->build_generation_speaker_map($detected_speakers, $speaker_map);
			if (count($generation_speaker_map) !== 2) {
				return new WP_Error('invalid_speaker_count', 'Exactly two speakers are required for multi-speaker generation.');
			}
			$voice_summary_label     = $this->build_voice_summary_label($script_mode, $voice_id, $speaker_map, $detected_speakers);
			$stored_voice_label      = $voice_summary_label;
			$stored_speaker_map_json = wp_json_encode($speaker_map);
			$speaker_count           = count($detected_speakers);
			$audio_data              = $this->generate_multi_speaker_audio_data($provider, $tts_model, $turns, $generation_speaker_map, $voice_direction);
		} else {
			$clean_text              = $this->normalize_script_plain_text($script_content);
			$voice_summary_label     = $this->build_voice_summary_label($script_mode, $voice_id, array(), array());
			$stored_voice_id         = $voice_id;
			$stored_voice_label      = $voice_id;
			$stored_speaker_map_json = wp_json_encode(array());
			$audio_data              = $this->generate_single_speaker_audio_data($provider, $tts_model, $clean_text, $voice_id, $voice_direction);
		}

		if (is_wp_error($audio_data)) {
			return $audio_data;
		}

		if (empty($audio_data)) {
			return new WP_Error('empty_audio', 'Generated audio data is empty.');
		}

		// Save audio file to uploads directory
		$file_ext   = 'wav';
		$upload_dir = wp_upload_dir();
		$filename   = 'podcast_audio_' . time() . '.' . $file_ext;
		$file_path  = $upload_dir['path'] . '/' . $filename;
		$file_url   = $upload_dir['url'] . '/' . $filename;

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if (file_put_contents($file_path, $audio_data) === false) {
			return new WP_Error('file_save_failed', 'Failed to save audio file to server.');
		}

		// Create media library attachment
		$file_type  = wp_check_filetype($filename, null);
		$attachment = array(
			'post_mime_type' => $file_type['type'],
			'post_title'     => 'Podcast Audio - ' . gmdate('Y-m-d H:i:s'),
			'post_content'   => '',
			'post_status'    => 'inherit',
		);

		$attach_id = wp_insert_attachment($attachment, $file_path, $script_post_id);

		if (!is_wp_error($attach_id)) {
			require_once(ABSPATH . 'wp-admin/includes/image.php');
			$attach_data = wp_generate_attachment_metadata($attach_id, $file_path);
			wp_update_attachment_metadata($attach_id, $attach_data);
		}

		// Update script post metadata
		if ($script_post_id > 0) {
			update_post_meta($script_post_id, '_Podcastmaker_audio_url', $file_url);
			update_post_meta($script_post_id, '_Podcastmaker_audio_id', $attach_id);
			update_post_meta($script_post_id, '_Podcastmaker_script_mode', $script_mode);
			update_post_meta($script_post_id, '_Podcastmaker_voice_id', $stored_voice_id);
			update_post_meta($script_post_id, '_Podcastmaker_voice_summary_label', $voice_summary_label);
			update_post_meta($script_post_id, '_Podcastmaker_multi_speaker_map', $stored_speaker_map_json);

			// Save audio-generated flag to source blog post (used by bulk "unvoiced only" filter)
			$source_post_id = absint(get_post_meta($script_post_id, '_Podcastmaker_source_post_id', true));
			if ($source_post_id > 0) {
				update_post_meta($source_post_id, '_podcastmaker_has_audio', '1');
			}
		}

		if (class_exists('Podcastmaker_Audio_Analytics')) {
			$source_post_id = $script_post_id > 0 ? absint(get_post_meta($script_post_id, '_Podcastmaker_source_post_id', true)) : 0;
			Podcastmaker_Audio_Analytics::register_audio_library_entry(array(
				'audio_url'          => $file_url,
				'attachment_id'      => $attach_id,
				'script_post_id'     => $script_post_id,
				'source_post_id'     => $source_post_id,
				'audio_mode'         => $script_mode,
				'voice_id'           => $stored_voice_id,
				'voice_label'        => $stored_voice_label,
				'voice_summary_label' => $voice_summary_label,
				'speaker_map_json'   => $stored_speaker_map_json,
				'speaker_count'      => $speaker_count,
			));
		}

		return array(
			'audio_url'           => $file_url,
			'audio_id'            => $attach_id,
			'voice_summary_label' => $voice_summary_label,
			'speaker_map_json'    => $stored_speaker_map_json,
		);
	}

	public function ajax_generate_voice()
	{
		// TTS生成は時間がかかる場合があるため、タイムアウトを延長
		if (function_exists('set_time_limit')) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_set_time_limit, Squiz.PHP.DiscouragedFunctions.Discouraged
			set_time_limit(300);
		}

		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$script_content = isset($_POST['text']) ? sanitize_textarea_field(wp_unslash($_POST['text'])) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$script_mode = $this->normalize_requested_script_mode(isset($_POST['script_mode']) ? wp_unslash($_POST['script_mode']) : 'single');
		$voice_id = isset($_POST['voice_id']) ? sanitize_text_field(wp_unslash($_POST['voice_id'])) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$speaker_map_json = isset($_POST['speaker_map_json']) ? wp_unslash($_POST['speaker_map_json']) : '';
		$speaker_map = $this->sanitize_speaker_map_json_payload($speaker_map_json);
		$voice_direction = isset($_POST['voice_direction']) ? sanitize_textarea_field(wp_unslash($_POST['voice_direction'])) : '';
		$tts_model = isset($_POST['tts_model_id']) ? sanitize_text_field(wp_unslash($_POST['tts_model_id'])) : '';
		$post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;

		if (!$this->can_current_user_edit_script_post($post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		$result = $this->generate_and_save_audio($post_id, $script_content, array(
			'tts_model'       => $tts_model,
			'script_mode'     => $script_mode,
			'voice_id'        => $voice_id,
			'speaker_map'     => $speaker_map,
			'voice_direction' => $voice_direction,
		));

		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
		}

		wp_send_json_success(array(
			'audio_url'           => $result['audio_url'],
			'attach_id'           => $result['audio_id'],
			'script_mode'         => $script_mode,
			'voice_summary_label' => $result['voice_summary_label'],
			'speaker_map_json'    => $result['speaker_map_json'],
		));
	}

	/**
	 * Expand the base prompt placeholder if the prompt set uses it.
	 *
	 * @param Podcastmaker_Prompt_Sets $prompt_manager Prompt set manager.
	 * @param string $prompt_text Prompt text.
	 * @param string $language Prompt language.
	 * @param string $speaker_type Prompt speaker type.
	 * @return string
	 */
	private function expand_prompt_with_base_prompt( $prompt_manager, $prompt_text, $language, $speaker_type )
	{
		if ( strpos( $prompt_text, '[baseprompt]' ) === false ) {
			return $prompt_text;
		}

		$base_prompt_text = $prompt_manager->get_base_prompt_text( $language, $speaker_type );
		$expanded_prompt = str_replace( '[baseprompt]', trim( $base_prompt_text ), $prompt_text );
		$expanded_prompt = preg_replace( "/\n{3,}/", "\n\n", $expanded_prompt );

		if ( $expanded_prompt === null ) {
			return $prompt_text;
		}

		return trim( $expanded_prompt );
	}

	/**
	 * Build a language-specific length hint for script generation.
	 *
	 * @param string $language Prompt language.
	 * @param string $speaker_type Prompt speaker type.
	 * @param string $length_key Selected duration key.
	 * @return string
	 */
	private function build_script_length_instruction( $language, $speaker_type, $length_key )
	{
		$normalized_language = $this->normalize_script_language_code( $language, 'en' );
		$normalized_speaker_type = $speaker_type === 'multi' ? 'multi' : 'single';
		$normalized_length_key = $this->sanitize_script_length_meta( $length_key );

		$duration_label_map = array(
			'30sec' => array(
				'ja' => '約30秒',
				'en' => 'about 30 seconds',
				'zh-cn' => '约30秒',
				'zh-tw' => '約30秒',
				'ko' => '약 30초',
			),
			'60sec' => array(
				'ja' => '約1分',
				'en' => 'about 1 minute',
				'zh-cn' => '约1分钟',
				'zh-tw' => '約1分鐘',
				'ko' => '약 1분',
			),
			'180sec' => array(
				'ja' => '約3分',
				'en' => 'about 3 minutes',
				'zh-cn' => '约3分钟',
				'zh-tw' => '約3分鐘',
				'ko' => '약 3분',
			),
		);

		$character_count_map = array(
			'30sec' => array(
				'ja' => '200〜250文字',
				'en' => '470 characters',
				'zh-cn' => '430字',
				'zh-tw' => '320字',
				'ko' => '425자',
			),
			'60sec' => array(
				'ja' => '400〜500文字',
				'en' => '930 characters',
				'zh-cn' => '860字',
				'zh-tw' => '640字',
				'ko' => '850자',
			),
			'180sec' => array(
				'ja' => '1,200〜1,500文字',
				'en' => '2,800 characters',
				'zh-cn' => '2,575字',
				'zh-tw' => '1,910字',
				'ko' => '2,550자',
			),
		);

		$duration_label = isset( $duration_label_map[ $normalized_length_key ] ) ? $duration_label_map[ $normalized_length_key ] : $duration_label_map['30sec'];
		$duration_label = isset( $duration_label[ $normalized_language ] ) ? $duration_label[ $normalized_language ] : $duration_label['en'];

		$character_count_text = isset( $character_count_map[ $normalized_length_key ] ) ? $character_count_map[ $normalized_length_key ] : $character_count_map['30sec'];
		$character_count_text = isset( $character_count_text[ $normalized_language ] ) ? $character_count_text[ $normalized_language ] : $character_count_text['en'];

		$instruction_map = array(
			'ja' => array(
				'single' => '通常のスピードで読んだときに、約%sを目安に、読み上げ時間は%sになるように、一人語りのスクリプト全体の長さを調整してください。',
				'multi' => '通常のスピードで読んだときに、約%sを目安に、読み上げ時間は%sになるように、会話全体の長さを調整してください。',
			),
			'en' => array(
				'single' => 'When read at a normal pace, adjust the full script to about %s, with a reading time of about %s.',
				'multi' => 'When read at a normal pace, adjust the full conversation to about %s, with a reading time of about %s.',
			),
			'zh-cn' => array(
				'single' => '请将整篇单人脚本调整为约%s，按正常语速朗读时约%s。',
				'multi' => '请将整段对话调整为约%s，按正常语速朗读时约%s。',
			),
			'zh-tw' => array(
				'single' => '請將整篇單人腳本調整為約%s，以正常語速朗讀時約%s。',
				'multi' => '請將整段對話調整為約%s，以正常語速朗讀時約%s。',
			),
			'ko' => array(
				'single' => '보통 속도로 읽었을 때 전체 단독 스크립트가 약 %s, 읽기 시간은 약 %s가 되도록 조정해 주세요.',
				'multi' => '보통 속도로 읽었을 때 전체 대화가 약 %s, 읽기 시간은 약 %s가 되도록 조정해 주세요.',
			),
		);

		$mode_instruction = isset( $instruction_map[ $normalized_language ][ $normalized_speaker_type ] )
			? $instruction_map[ $normalized_language ][ $normalized_speaker_type ]
			: $instruction_map['en'][ $normalized_speaker_type ];

		return sprintf( $mode_instruction, $character_count_text, $duration_label );
	}

	/**
	 * Prepare a multi-speaker TTS session and return the chunk plan.
	 */
	public function ajax_prepare_multi_speaker_voice()
	{
		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$script_content = isset($_POST['text']) ? sanitize_textarea_field(wp_unslash($_POST['text'])) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$speaker_map_json = isset($_POST['speaker_map_json']) ? wp_unslash($_POST['speaker_map_json']) : '';
		$speaker_map = $this->sanitize_speaker_map_json_payload($speaker_map_json);
		$voice_direction = isset($_POST['voice_direction']) ? sanitize_textarea_field(wp_unslash($_POST['voice_direction'])) : '';
		$tts_model = isset($_POST['tts_model_id']) ? sanitize_text_field(wp_unslash($_POST['tts_model_id'])) : get_option('Podcastmaker_gemini_tts_model', 'gemini-2.5-flash-preview-tts');
		$post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;

		if (!$this->can_current_user_edit_script_post($post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		if (empty($script_content) || mb_strlen($script_content) > 50000) {
			wp_send_json_error(array('message' => 'Invalid or too long script content.'));
		}
		if (!Podcastmaker_License::is_pro()) {
			wp_send_json_error(array('message' => 'Multi-speaker voice generation is available for Pro and Business plans only.'));
		}
		if (count($speaker_map) === 0) {
			wp_send_json_error(array('message' => 'Speaker voice mapping is required for multi-speaker generation.'));
		}

		$provider = $this->get_effective_tts_provider('multi');
		$turns = $this->parse_multi_speaker_turns_with_parser_model($script_content, $provider);
		if (is_wp_error($turns)) {
			wp_send_json_error(array('message' => $turns->get_error_message()));
		}

		$detected_speakers = $this->get_unique_speakers_from_turns($turns);
		if (count($detected_speakers) < 2) {
			wp_send_json_error(array('message' => 'Exactly two speakers are required for multi-speaker generation.'));
		}
		if (count($detected_speakers) > 2) {
			wp_send_json_error(array('message' => 'Gemini TTS supports exactly two speakers. Three or more speakers were detected.'));
		}

		foreach ($detected_speakers as $speaker_name) {
			$normalized_speaker_name = $this->normalize_speaker_name_key($speaker_name);
			if ($normalized_speaker_name === '' || !isset($speaker_map[$normalized_speaker_name]) || $speaker_map[$normalized_speaker_name] === '') {
				wp_send_json_error(array('message' => sprintf('A voice is missing for speaker "%s".', $speaker_name)));
			}
		}

		$normalized_detected_speakers = array_map(array($this, 'normalize_speaker_name_key'), $detected_speakers);
		$speaker_map = array_intersect_key($speaker_map, array_flip($normalized_detected_speakers));
		$generation_speaker_map = $this->build_generation_speaker_map($detected_speakers, $speaker_map);
		if (count($generation_speaker_map) !== 2) {
			wp_send_json_error(array('message' => 'Exactly two speakers are required for multi-speaker generation.'));
		}
		$chunk_plan = $this->build_multi_speaker_chunk_plan($turns, $voice_direction);
		if (empty($chunk_plan)) {
			wp_send_json_error(array('message' => 'No valid dialogue turns were found for chunk generation.'));
		}

		$session_id = wp_generate_password(20, false, false);
		$voice_summary_label = $this->build_voice_summary_label('multi', '', $speaker_map, $detected_speakers);
		$session_payload = array(
			'provider' => $provider,
			'tts_model' => $tts_model,
			'post_id' => $post_id,
			'voice_direction' => $voice_direction,
			'speaker_map' => $generation_speaker_map,
			'stored_speaker_map_json' => wp_json_encode($speaker_map),
			'voice_summary_label' => $voice_summary_label,
			'speaker_count' => count($detected_speakers),
			'chunks' => $chunk_plan,
			'chunk_file_paths' => array(),
		);

		$this->save_multi_speaker_session($session_id, $session_payload);

		$chunk_summary = array_map(function ($chunk, $index) {
			$turn_numbers = array();
			foreach ($chunk['turns'] as $turn) {
				if (isset($turn['turn'])) {
					$turn_numbers[] = absint($turn['turn']);
				}
			}

			return array(
				'chunkIndex' => $index,
				'turnCount' => count($chunk['turns']),
				'turnRange' => !empty($turn_numbers) ? array(min($turn_numbers), max($turn_numbers)) : array(),
				'payloadBytes' => isset($chunk['payload_bytes']) ? absint($chunk['payload_bytes']) : 0,
			);
		}, $chunk_plan, array_keys($chunk_plan));

		wp_send_json_success(array(
			'session_id' => $session_id,
			'total_chunks' => count($chunk_plan),
			'chunk_plan' => $chunk_summary,
			'voice_summary_label' => $voice_summary_label,
			'speaker_map_json' => wp_json_encode($speaker_map),
		));
	}

	/**
	 * Generate one prepared multi-speaker TTS chunk and store its PCM data in a temporary file.
	 */
	public function ajax_generate_multi_speaker_chunk()
	{
		if (function_exists('set_time_limit')) {
			// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.runtime_configuration_set_time_limit, Squiz.PHP.DiscouragedFunctions.Discouraged
			set_time_limit(300);
		}

		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$session_id = isset($_POST['session_id']) ? sanitize_text_field(wp_unslash($_POST['session_id'])) : '';
		$chunk_index = isset($_POST['chunk_index']) ? absint($_POST['chunk_index']) : -1;
		$session_payload = $this->get_multi_speaker_session($session_id);
		if (!$session_payload) {
			wp_send_json_error(array('message' => 'The multi-speaker TTS session could not be found. Please restart generation.'));
		}

		$session_post_id = isset($session_payload['post_id']) ? absint($session_payload['post_id']) : 0;
		if (!$this->can_current_user_edit_script_post($session_post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		if (!isset($session_payload['chunks'][$chunk_index])) {
			wp_send_json_error(array('message' => 'The requested chunk index is invalid.'));
		}

		if (!isset($session_payload['chunk_file_paths']) || !is_array($session_payload['chunk_file_paths'])) {
			$session_payload['chunk_file_paths'] = array();
		}

		if (!empty($session_payload['chunk_file_paths'][$chunk_index]) && file_exists($session_payload['chunk_file_paths'][$chunk_index])) {
			$completed_chunks = count(array_filter($session_payload['chunk_file_paths'], 'file_exists'));
			wp_send_json_success(array(
				'chunk_index' => $chunk_index,
				'total_chunks' => count($session_payload['chunks']),
				'completed_chunks' => $completed_chunks,
				'cached' => true,
			));
		}

		$chunk_turns = $session_payload['chunks'][$chunk_index]['turns'];
		$audio_data = $this->generate_multi_speaker_audio_data(
			$session_payload['provider'],
			$session_payload['tts_model'],
			$chunk_turns,
			$session_payload['speaker_map'],
			isset($session_payload['voice_direction']) ? $session_payload['voice_direction'] : ''
		);

		if (is_wp_error($audio_data)) {
			wp_send_json_error(array('message' => $audio_data->get_error_message()));
		}

		$pcm_data = $this->extract_pcm_from_wav($audio_data);
		if ($pcm_data === '') {
			wp_send_json_error(array('message' => 'Chunk audio was generated, but the PCM payload could not be extracted.'));
		}

		$chunk_file_path = $this->write_multi_speaker_chunk_file($session_id, $chunk_index, $pcm_data);
		if (is_wp_error($chunk_file_path)) {
			wp_send_json_error(array('message' => $chunk_file_path->get_error_message()));
		}

		$session_payload['chunk_file_paths'][$chunk_index] = $chunk_file_path;
		$this->save_multi_speaker_session($session_id, $session_payload);

		$completed_chunks = count(array_filter($session_payload['chunk_file_paths'], 'file_exists'));
		wp_send_json_success(array(
			'chunk_index' => $chunk_index,
			'total_chunks' => count($session_payload['chunks']),
			'completed_chunks' => $completed_chunks,
		));
	}

	/**
	 * Finalize a prepared multi-speaker TTS session by combining PCM chunks into one WAV.
	 */
	public function ajax_finalize_multi_speaker_voice()
	{
		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$session_id = isset($_POST['session_id']) ? sanitize_text_field(wp_unslash($_POST['session_id'])) : '';
		$session_payload = $this->get_multi_speaker_session($session_id);
		if (!$session_payload) {
			wp_send_json_error(array('message' => 'The multi-speaker TTS session could not be found. Please restart generation.'));
		}

		$session_post_id = isset($session_payload['post_id']) ? absint($session_payload['post_id']) : 0;
		if (!$this->can_current_user_edit_script_post($session_post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		$total_chunks = isset($session_payload['chunks']) && is_array($session_payload['chunks']) ? count($session_payload['chunks']) : 0;
		$chunk_file_paths = isset($session_payload['chunk_file_paths']) && is_array($session_payload['chunk_file_paths']) ? $session_payload['chunk_file_paths'] : array();
		if ($total_chunks === 0 || count($chunk_file_paths) !== $total_chunks) {
			wp_send_json_error(array('message' => 'Not all chunks have been generated yet.'));
		}

		$save_result = $this->save_multi_speaker_audio_from_chunk_files(
			$chunk_file_paths,
			isset($session_payload['post_id']) ? absint($session_payload['post_id']) : 0,
			isset($session_payload['voice_summary_label']) ? (string) $session_payload['voice_summary_label'] : '',
			isset($session_payload['stored_speaker_map_json']) ? (string) $session_payload['stored_speaker_map_json'] : wp_json_encode(array()),
			isset($session_payload['speaker_count']) ? absint($session_payload['speaker_count']) : 2
		);

		$this->delete_multi_speaker_session($session_id);

		if (is_wp_error($save_result)) {
			wp_send_json_error(array('message' => $save_result->get_error_message()));
		}

		wp_send_json_success($save_result);
	}

	/**
	 * Save a multi-speaker WAV file from chunk PCM files without loading everything into one transient.
	 *
	 * @param array<int, string> $chunk_file_paths PCM file paths.
	 * @param int                $post_id The script post ID.
	 * @param string             $voice_summary_label The summary label.
	 * @param string             $stored_speaker_map_json The stored speaker map JSON.
	 * @param int                $speaker_count Number of speakers.
	 * @return array<string, mixed>|WP_Error
	 */
	private function save_multi_speaker_audio_from_chunk_files($chunk_file_paths, $post_id, $voice_summary_label, $stored_speaker_map_json, $speaker_count)
	{
		$total_chunks = count($chunk_file_paths);
		$total_pcm_bytes = 0;
		for ($chunk_index = 0; $chunk_index < $total_chunks; $chunk_index++) {
			if (empty($chunk_file_paths[$chunk_index]) || !file_exists($chunk_file_paths[$chunk_index])) {
				return new WP_Error('missing_chunk_file', sprintf('Chunk %d is missing and the audio could not be finalized.', $chunk_index + 1));
			}
			$chunk_size = filesize($chunk_file_paths[$chunk_index]);
			if ($chunk_size === false) {
				return new WP_Error('chunk_size_error', sprintf('Could not read the size of chunk %d.', $chunk_index + 1));
			}
			$total_pcm_bytes += (int) $chunk_size;
		}

		$destination = $this->create_generated_audio_file_path('wav');
		if (is_wp_error($destination)) {
			return $destination;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		$file_handle = fopen($destination['file_path'], 'wb');
		if ($file_handle === false) {
			return new WP_Error('file_open_error', 'Failed to open the destination audio file for writing.');
		}

		$header = $this->build_wav_header($total_pcm_bytes);
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
		if (fwrite($file_handle, $header) === false) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose($file_handle);
			return new WP_Error('file_write_error', 'Failed to write the WAV header to the destination file.');
		}

		foreach ($chunk_file_paths as $chunk_file_path) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
			$chunk_handle = fopen($chunk_file_path, 'rb');
			if ($chunk_handle === false) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
				fclose($file_handle);
				return new WP_Error('chunk_open_error', 'Failed to open one of the PCM chunk files.');
			}

			stream_copy_to_stream($chunk_handle, $file_handle);
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			fclose($chunk_handle);
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
		fclose($file_handle);

		return $this->persist_generated_audio_file(
			$destination['filename'],
			$destination['file_path'],
			$destination['file_url'],
			'multi',
			$post_id,
			'',
			$voice_summary_label,
			$stored_speaker_map_json,
			$speaker_count
		);
	}

	/**
	 * AJAXハンドラ: 生成された音声を記事に挿入する。
	 */
	public function ajax_insert_audio_to_post()
	{
		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$target_post_id = isset($_POST['target_post_id']) ? absint($_POST['target_post_id']) : 0;
		// V-04: HTMLコンテキスト用のエスケープとして esc_url() を使用する
		$audio_url = isset($_POST['audio_url']) ? esc_url_raw(wp_unslash($_POST['audio_url'])) : '';
		$attach_id = isset($_POST['attach_id']) ? absint($_POST['attach_id']) : 0;
		$voice_id = isset($_POST['voice_id']) ? sanitize_text_field(wp_unslash($_POST['voice_id'])) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$audio_mode = $this->normalize_requested_script_mode(isset($_POST['audio_mode']) ? wp_unslash($_POST['audio_mode']) : 'single');
		$voice_summary_label = isset($_POST['voice_summary_label']) ? sanitize_text_field(wp_unslash($_POST['voice_summary_label'])) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$speaker_map_json = isset($_POST['speaker_map_json']) ? wp_unslash($_POST['speaker_map_json']) : '';

		if (empty($target_post_id) || empty($audio_url)) {
			wp_send_json_error(array('message' => 'Missing post ID or audio URL.'));
		}

		$post = get_post($target_post_id);
		if (!$post) {
			wp_send_json_error(array('message' => 'Target post not found.'));
		}

		if (!$this->can_current_user_edit_script_post($target_post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		// オーディオブロックのHTMLを作成
		// 最小限のブロックフォーマット
		// $audio_block: 挿入するオーディオブロックHTML
		$audio_attrs = array(
			'controls' => 'controls',
			'src' => $audio_url,
		);

		$attr_html = '';
		foreach ($audio_attrs as $attr_name => $attr_value) {
			if ($attr_value === '') {
				continue;
			}
			if ($attr_name === 'controls') {
				$attr_html .= ' controls';
				continue;
			}
			$attr_html .= ' ' . $attr_name . '="' . esc_attr($attr_value) . '"';
		}

		$audio_block = '<!-- wp:audio -->
<figure class="wp-block-audio"><audio' . $attr_html . '></audio></figure>
<!-- /wp:audio -->';

		// コンテンツの先頭に追加
		// $updated_content: 更新後のコンテンツ
		$updated_content = $audio_block . "\n\n" . $post->post_content;

		$updated = wp_update_post(array(
			'ID' => $target_post_id,
			'post_content' => $updated_content,
		));

		if (is_wp_error($updated)) {
			wp_send_json_error(array('message' => 'Failed to update post.'));
		}

		if (class_exists('Podcastmaker_Audio_Analytics')) {
			$script_post_id = isset($_POST['script_post_id']) ? absint($_POST['script_post_id']) : 0;
			Podcastmaker_Audio_Analytics::register_audio_library_entry(array(
				'audio_url' => $audio_url,
				'attachment_id' => $attach_id,
				'script_post_id' => $script_post_id,
				'source_post_id' => $target_post_id,
				'audio_mode' => $audio_mode,
				'voice_id' => $voice_id,
				'voice_label' => $voice_id,
				'voice_summary_label' => $voice_summary_label,
				'speaker_map_json' => $speaker_map_json,
			));
		}

		wp_send_json_success();
	}

	/**
	 * ブロックエディタ用のアセットをエンキューする。
	 */
	public function enqueue_editor_assets()
	{
		$screen = get_current_screen();
		if (!$screen) {
			return;
		}

		$post_type = $screen->post_type;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if (empty($post_type) && isset($_GET['post_type'])) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$post_type = sanitize_text_field(wp_unslash($_GET['post_type']));
		}

		// 1. Blog Post and Page View
		if (in_array($post_type, array('post', 'page'))) {
			wp_enqueue_script(
				'Podcastmaker-post-sidebar',
				plugin_dir_url(__FILE__) . '../assets/js/post-sidebar.js',
				array('wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data'),
				time(),
				true
			);
			if (function_exists('wp_set_script_translations')) {
				wp_set_script_translations(
					'Podcastmaker-post-sidebar',
					'quikvox-ai',
					dirname(plugin_dir_path(__FILE__)) . '/languages'
				);
			}
			wp_localize_script('Podcastmaker-post-sidebar', 'PodcastmakerPostData', array(
				'adminUrl' => admin_url(),
			));
			return;
		}

		// 2. Podcast Script Generator View
		if ('podcastmaker_script' === $post_type) {
			wp_enqueue_script(
				'Podcastmaker-script-generator',
				plugin_dir_url(__FILE__) . '../assets/js/script-generator.js',
				array('wp-plugins', 'wp-edit-post', 'wp-element', 'wp-components', 'wp-data', 'wp-compose', 'wp-api-fetch', 'wp-url', 'wp-blocks', 'wp-rich-text', 'wp-block-editor'),
				time(),
				true
			);
			if (function_exists('wp_set_script_translations')) {
				wp_set_script_translations(
					'Podcastmaker-script-generator',
					'quikvox-ai',
					dirname(plugin_dir_path(__FILE__)) . '/languages'
				);
			}

			// UI adjustment CSS
			wp_enqueue_style(
				'Podcastmaker-script-generator-css',
				plugin_dir_url(__FILE__) . '../assets/css/script-generator.css',
				array(),
				time()
			);

			// JS用のデータを準備
			// 1. プロンプトセット
			if (!class_exists('Podcastmaker_Prompt_Sets')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-prompt-sets.php';
			}
			$prompt_manager = new Podcastmaker_Prompt_Sets();
			$prompt_sets = $prompt_manager->get_all();

			// 2. Provider情報とモデル・ボイスリスト
			$provider = get_option('podcastmaker_api_provider', 'google_ai_studio');

			$voices = array();
			$gemini_models = array();
			$current_model = '';
			$tts_models_config = array();
			$default_tts_model = '';

			if ($provider === 'vertex_ai') {
				// --- Vertex AI データ ---
				if (!class_exists('Podcastmaker_API_Vertex')) {
					require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
				}
				$vertex_json = get_option('podcastmaker_vertex_auth_json', '');
				$vertex_location = get_option('podcastmaker_vertex_location', 'global');

			if (!empty($vertex_json)) {
					$vertex_api = new Podcastmaker_API_Vertex($vertex_json, $vertex_location);
					$voices = $vertex_api->get_voices();
				}

				$current_model = $this->normalize_text_model(get_option('podcastmaker_vertex_model', 'gemini-2.5-flash'), 'gemini-2.5-flash');
				$available_models_saved = get_option('podcastmaker_vertex_available_models', array('gemini-2.5-flash', 'gemini-2.5-pro'));
				$gemini_models = $this->normalize_model_list($available_models_saved, array('gemini-2.5-flash', 'gemini-2.5-pro'), 'text');
				$current_model = $this->resolve_available_model($current_model, $gemini_models, 'gemini-2.5-flash');

				$default_tts_model = $this->normalize_vertex_tts_model(get_option('podcastmaker_vertex_tts_model', 'gemini-2.5-flash-tts'));
				$available_tts_saved = get_option('podcastmaker_vertex_available_tts_models', array('gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-tts', 'gemini-2.5-pro-tts', 'gemini-2.5-flash-lite-preview-tts'));

				$all_tts_options = array(
					'gemini-3.1-flash-tts-preview' => array('value' => 'gemini-3.1-flash-tts-preview', 'label' => 'gemini-3.1-flash-tts-preview', 'cost' => 'Cloud TTS Pricing'),
					'gemini-2.5-flash-tts' => array('value' => 'gemini-2.5-flash-tts', 'label' => 'gemini-2.5-flash-tts', 'cost' => 'Cloud TTS Pricing'),
					'gemini-2.5-pro-tts' => array('value' => 'gemini-2.5-pro-tts', 'label' => 'gemini-2.5-pro-tts', 'cost' => 'Cloud TTS Pricing'),
					'gemini-2.5-flash-lite-preview-tts' => array('value' => 'gemini-2.5-flash-lite-preview-tts', 'label' => 'gemini-2.5-flash-lite-preview-tts', 'cost' => 'Cloud TTS Pricing'),
				);

				$tts_models_config = array();
				if (is_array($available_tts_saved)) {
					foreach ($this->normalize_model_list($available_tts_saved, array('gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-tts', 'gemini-2.5-pro-tts', 'gemini-2.5-flash-lite-preview-tts'), 'vertex_tts') as $slug) {
						if (isset($all_tts_options[$slug])) {
							$tts_models_config[] = $all_tts_options[$slug];
						}
					}
				}
				if (empty($tts_models_config)) {
					$tts_models_config = array_values($all_tts_options);
				}

			}
			else {
				// --- Google AI Studio データ ---
				$gemini_tts_api_key = get_option('Podcastmaker_gemini_tts_api_key');

				if (!empty($gemini_tts_api_key)) {
					if (!class_exists('Podcastmaker_API_Gemini')) {
						require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
					}
					$gemini_api = new Podcastmaker_API_Gemini($gemini_tts_api_key);
					$all_voices = $gemini_api->get_voices();
					if (!is_wp_error($all_voices)) {
						$voices = $all_voices;
					}
				}

				$current_model = $this->normalize_text_model(get_option('Podcastmaker_gemini_model', 'gemini-2.5-flash'), 'gemini-2.5-flash');
				$available_models_saved = get_option('Podcastmaker_gemini_available_models');
				$gemini_models = $this->normalize_model_list($available_models_saved, array('gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash-lite'), 'text');
				$current_model = $this->resolve_available_model($current_model, $gemini_models, 'gemini-2.5-flash');

				$default_tts_model = $this->normalize_gemini_tts_model(get_option('Podcastmaker_gemini_tts_model', 'gemini-2.5-flash-preview-tts'));
				$available_tts_saved = get_option('Podcastmaker_gemini_available_tts_models');

				$all_tts_options = array(
					'gemini-3.1-flash-tts-preview' => array('value' => 'gemini-3.1-flash-tts-preview', 'label' => 'Gemini 3.1 Flash TTS Preview', 'cost' => 'Cloud TTS Pricing'),
					'gemini-2.5-flash-preview-tts' => array('value' => 'gemini-2.5-flash-preview-tts', 'label' => 'Gemini 2.5 Flash Preview TTS', 'cost' => 'Text: $0.50, Audio: $10.00 / 1M'),
					'gemini-2.5-pro-preview-tts' => array('value' => 'gemini-2.5-pro-preview-tts', 'label' => 'Gemini 2.5 Pro Preview TTS', 'cost' => 'Text: $1.00, Audio: $20.00 / 1M'),
				);

				$tts_models_config = array();
				if (!empty($available_tts_saved) && is_array($available_tts_saved)) {
					foreach ($this->normalize_model_list($available_tts_saved, array('gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-preview-tts'), 'gemini_tts') as $slug) {
						if (isset($all_tts_options[$slug])) {
							$tts_models_config[] = $all_tts_options[$slug];
						}
					}
				}
				else {
					$tts_models_config = array_values($all_tts_options);
				}
			}

			if (!class_exists('Podcastmaker_Smart_Tone')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-smart-tone.php';
			}

			wp_localize_script('Podcastmaker-script-generator', 'PodcastmakerScriptData', array(
				'ajaxUrl' => admin_url('admin-ajax.php'),
				'adminUrl' => admin_url(),
				'siteLocale' => get_locale(),
				'nonce' => wp_create_nonce('Podcastmaker_script_nonce'),
				'promptSets' => $prompt_sets,
				'promptSetDefaults' => array(
					'single' => $prompt_manager->get_default_prompt_set_id('single'),
					'multi' => $prompt_manager->get_default_prompt_set_id('multi'),
				),
				'voices' => $voices,
				'geminiModels' => $gemini_models,
				'currentGeminiModel' => $current_model,
				'ttsModels' => $tts_models_config,
				'defaultTtsModel' => $default_tts_model,
				'assetsUrl' => plugin_dir_url(dirname(__FILE__)),
				'apiProvider' => $provider,
				'isPro' => Podcastmaker_License::is_pro(),
				'isBusiness' => Podcastmaker_License::is_business(),
				'currentPlan' => Podcastmaker_License::get_plan(),
				'smartToneStyles' => $this->get_smart_tone_style_directives(true),
				'smartToneDefaults' => array(
					'single' => Podcastmaker_Smart_Tone::get_default_style_id('single'),
					'multi' => Podcastmaker_Smart_Tone::get_default_style_id('multi'),
				),
				'multiSpeakerChunkConfig' => array(
					'maxTotalBytes' => $this->get_multi_speaker_chunk_max_total_bytes(),
				),
			));
		}
	}

	/**
	 * スクリプト一覧のカスタムカラムを定義する。
	 */
	public function set_custom_edit_columns($columns)
	{
		// 新しいカラム順序
		$new_columns = array();
		$new_columns['cb'] = $columns['cb'];
		$new_columns['index_id'] = __('ID', 'quikvox-ai');
		$new_columns['title'] = __('Source Article (Title)', 'quikvox-ai');
		$new_columns['preview'] = __('Content Preview', 'quikvox-ai');
		$new_columns['mode'] = __('Mode', 'quikvox-ai');
		$new_columns['voices'] = __('Voices', 'quikvox-ai');
		$new_columns['audio'] = __('Audio', 'quikvox-ai');
		$new_columns['date'] = $columns['date'];

		return $new_columns;
	}

	/**
	 * Build a readable content preview for the scripts list table.
	 *
	 * @param string $content Raw post content.
	 * @return string
	 */
	private function build_content_preview_text($content)
	{
		$plain_content = wp_strip_all_tags(strip_shortcodes($content));
		$normalized_content = preg_replace('/\s+/u', ' ', trim($plain_content));

		if ($normalized_content === null || $normalized_content === '') {
			return '';
		}

		if (function_exists('mb_strimwidth')) {
			return mb_strimwidth($normalized_content, 0, 240, '…', 'UTF-8');
		}

		if (function_exists('mb_substr')) {
			$preview_text = mb_substr($normalized_content, 0, 240, 'UTF-8');
			$needs_ellipsis = function_exists('mb_strlen') ? mb_strlen($normalized_content, 'UTF-8') > 240 : strlen($normalized_content) > 240;
			return $preview_text . ($needs_ellipsis ? '…' : '');
		}

		$preview_text = substr($normalized_content, 0, 240);
		return $preview_text . (strlen($normalized_content) > 240 ? '…' : '');
	}

	/**
	 * カスタムカラムのコンテンツを表示する。
	 */
	public function custom_column($column, $post_id)
	{
		switch ($column) {
			case 'index_id':
				$index_id = get_post_meta($post_id, '_Podcastmaker_index_id', true);
				// IDがまだない場合 (既存の投稿)、表示用に動的に割り当てて保存する
				if (empty($index_id)) {
					$next_id = (int)get_option('Podcastmaker_script_next_id', 1);
					update_post_meta($post_id, '_Podcastmaker_index_id', $next_id);
					update_option('Podcastmaker_script_next_id', $next_id + 1);
					$index_id = $next_id;
				}
				echo '<strong>' . esc_html($index_id) . '</strong>';
				break;

			case 'preview':
				$content = get_post_field('post_content', $post_id);
				$excerpt = $this->build_content_preview_text($content);
				echo '<div class="Podcastmaker-content-preview" dir="ltr">' .
					(!empty($excerpt) ? esc_html($excerpt) : '-') .
					'</div>';
				break;

			case 'mode':
				$script_mode = $this->get_script_mode_for_post($post_id);
				$mode_class = $script_mode === 'multi' ? 'multi' : 'single';
				$mode_label = $script_mode === 'multi' ? 'Multi' : 'Single';
				echo '<span class="Podcastmaker-mode-badge is-' . esc_attr($mode_class) . '">' . esc_html($mode_label) . '</span>';
				break;

			case 'voices':
				$voice_summary_label = $this->get_voice_summary_label_for_post($post_id);
				if (!empty($voice_summary_label)) {
					echo '<div class="Podcastmaker-voice-summary-cell">' . esc_html($voice_summary_label) . '</div>';
				}
				else {
					echo '<span class="description">-</span>';
				}
				break;

			case 'audio':
				$audio_url = get_post_meta($post_id, '_Podcastmaker_audio_url', true);
				if (!empty($audio_url)) {
					echo '<span class="dashicons dashicons-microphone" style="color:#46b450;" title="Audio Generated"></span>';
					echo ' <a href="' . esc_url($audio_url) . '" target="_blank" style="font-size:11px;">Play</a>';
				}
				else {
					echo '<span class="dashicons dashicons-no-alt" style="color:#d63638;" title="No Audio"></span>';
				}
				break;
		}
	}

	/**
	 * タイトルカラムの内容を元記事情報に置き換える。
	 */
	public function modify_title_column($actions, $post)
	{
		if ($post->post_type !== 'podcastmaker_script') {
			return $actions;
		}

		// タイトル置換は enqueue_admin_assets() で wp_localize_script 経由で admin.js に渡す。

		return $actions;
	}

	/**
	 * 管理画面リストのスクリプトタイトルをオーバーライドして、元記事のタイトルを表示する。
	 */
	public function admin_script_title($title, $post_id)
	{
		if (!is_admin())
			return $title;

		$post = get_post($post_id);
		if (!$post || $post->post_type !== 'podcastmaker_script')
			return $title;

		// WordPress の空タイトルのデフォルトは '(no title)' など。
		// 実際の post_title が空の場合、またはフォールバック文字列と一致する場合のみオーバーライドしたい。
		$raw_title = get_post_field('post_title', $post_id, 'raw');

		if (empty(trim($raw_title))) {
			$source_id = get_post_meta($post_id, '_Podcastmaker_source_post_id', true);
			if (!empty($source_id)) {
				$source_post = get_post($source_id);
				if ($source_post) {
					return '📄 ' . $source_post->post_title;
				}
			}
		}

		return $title;
	}

	/**
	 * AJAXハンドラ: Gemini経由でプロンプトを翻訳する。
	 */
	public function ajax_translate_prompt()
	{
		check_ajax_referer('Podcastmaker_translate_nonce', 'nonce');

		if (!current_user_can('manage_options')) {
			wp_send_json_error('Permission denied.');
		}

		$target_lang = isset($_POST['target_lang']) ? sanitize_text_field(wp_unslash($_POST['target_lang'])) : '';
		$name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '';
		$prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : '';

		if (empty($target_lang)) {
			wp_send_json_error('Missing target language.');
		}

		if (empty($name) && empty($prompt)) {
			wp_send_json_error('Missing name and prompt parameters.');
		}

		if (!empty($prompt) && mb_strlen($prompt) > 5000) {
			wp_send_json_error('Prompt content is too long.');
		}

		// $lang_names: 言語コードと名前の対応配列
		$lang_names = array(
			'ja' => 'Japanese',
			'en' => 'English',
			'zh-cn' => 'Chinese (Simplified)',
			'zh-tw' => 'Chinese (Traditional)',
			'ko' => 'Korean',
		);

		// $target_name: ターゲット言語の英語名
		$target_name = isset($lang_names[$target_lang]) ? $lang_names[$target_lang] : $target_lang;

		// $system_prompt: 翻訳者としてのAI設定プロンプト
		$system_prompt = "You are a professional translator specializing in creative content and AI prompt engineering. 
		Translate the following 'Name' and 'Prompt' for a podcast script generator into {$target_name}.

		Length Metric & Volume Calibration:
		1. Use the most natural metric for text length in the target language (e.g., use 'Words' for English, and 'Characters' for Japanese, Chinese, or Korean).
		2. Adjust any numeric length constraints to maintain equivalent content volume. For example, if the original specifies '200 characters' in Japanese, calibrate the translation to specify approximately '120-130 words' in English, accounting for the difference in information density per unit.

		Maintain the original meaning, tone, and functional nuances of the instructions.
		Return the result in JSON format: {\"name\": \"...\", \"prompt\": \"...\"}
		IMPORTANT: Output ONLY a raw JSON object. Do NOT include markdown code fences, explanations, or any other text outside the JSON object.";

		// $input_text: 翻訳対象のテキスト
		$input_text = "Name: {$name}\nPrompt: {$prompt}";

		$api_key = get_option('Podcastmaker_gemini_api_key');
		if (empty($api_key)) {
			wp_send_json_error('API Key missing.');
		}

		$model = get_option('Podcastmaker_gemini_model', 'gemini-2.5-flash');
		if (empty($model)) {
			$model = 'gemini-2.5-flash';
		}
		$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$api_key}";

		// response_mime_type: 'application/json' は Gemini 2.5 Thinking モデルと相性が悪いため使用しない。
		// プロンプトで明示的にJSONのみを返すよう指示している。
		$body = array(
			'contents' => array(
				array(
					'parts' => array(
						array('text' => $system_prompt . "\n\n" . $input_text)
					)
				)
			),
		);

		$response = wp_remote_post($url, array(
			'headers' => array('Content-Type' => 'application/json'),
			'body'    => wp_json_encode($body),
			// Gemini 2.5のthinking処理に時間がかかる場合があるため60秒に設定
			'timeout' => 60,
		));

		if (is_wp_error($response)) {
			wp_send_json_error($response->get_error_message());
		}

		// $res_body: APIレスポンス配列
		$res_body = json_decode(wp_remote_retrieve_body($response), true);

		if (!empty($res_body['error'])) {
			wp_send_json_error('Gemini API Error: ' . $res_body['error']['message']);
		}

		// $json_text: AIから返されたJSON文字列（未定義エラー防止のため事前初期化）
		$json_text = '';

		// Gemini 2.5 Thinkingモデルはparts[0]がthinkingコンテンツ（textキーなし）になる場合がある。
		// 全partsを走査してtextを持つ最初のpartを使用する。
		if (!empty($res_body['candidates'][0]['content']['parts'])) {
			foreach ($res_body['candidates'][0]['content']['parts'] as $part) {
				if (!empty($part['text'])) {
					$json_text = trim($part['text']);
					break;
				}
			}
		}

		if (!empty($json_text)) {
			// マークダウンのコードブロックがある場合は除去する
			if (strpos($json_text, '```') !== false) {
				$json_text = preg_replace('/```(?:json)?\s*/i', '', $json_text);
				$json_text = trim($json_text);
			}

			// JSON範囲だけ抽出する（前後に余分なテキストがある場合に対応）
			if (preg_match('/\{.*\}/s', $json_text, $matches)) {
				$json_text = $matches[0];
			}

			$translated = json_decode($json_text, true);
			if ($translated && isset($translated['name'], $translated['prompt'])) {
				wp_send_json_success($translated);
			}

			wp_send_json_error('Failed to parse AI response. Response text: ' . mb_strimwidth($json_text, 0, 200, '...'));
		}

		// candidatesはあるがtextが全く取れなかった場合
		if (isset($res_body['candidates'][0]['finishReason'])) {
			wp_send_json_error('Translation blocked by API. Reason: ' . $res_body['candidates'][0]['finishReason']);
		}

		// その他の場合
		$raw = wp_remote_retrieve_body($response);
		wp_send_json_error('No text content returned from API. Raw: ' . mb_strimwidth($raw, 0, 200, '...'));
	}

	/**
	 * AJAXハンドラ: AIを使用してスクリプトに感情演出タグを付与する。
	 */
	public function ajax_apply_smart_tone()
	{
		check_ajax_referer('Podcastmaker_script_nonce', 'nonce');

		$post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
		if (!$this->can_current_user_edit_script_post($post_id)) {
			wp_send_json_error(array('message' => 'Permission denied.'));
		}

		if (!class_exists('Podcastmaker_License')) {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-license.php';
		}

		if (!Podcastmaker_License::is_pro()) {
			wp_send_json_error(array('message' => 'This feature requires a Pro or Business plan.'));
		}

		// V-11: 他のAJAXハンドラとの一貫性のために sanitize_textarea_field を使用する
		$text = isset($_POST['text']) ? sanitize_textarea_field(wp_unslash($_POST['text'])) : '';
		$style_key = isset($_POST['style']) ? sanitize_text_field(wp_unslash($_POST['style'])) : Podcastmaker_Smart_Tone::get_default_style_id('single');
		$voice_direction = isset($_POST['voice_direction']) ? sanitize_textarea_field(wp_unslash($_POST['voice_direction'])) : '';

		if (empty($text)) {
			wp_send_json_error(array('message' => 'Script text is empty.'));
		}

		require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-smart-tone.php';
		$smart_tone_mgr = new Podcastmaker_Smart_Tone();
		$config = $smart_tone_mgr->get_config();
		$style_definition = Podcastmaker_Smart_Tone::find_style($style_key);
		if (!$style_definition) {
			$style_definition = Podcastmaker_Smart_Tone::find_style(Podcastmaker_Smart_Tone::get_default_style_id('single'));
		}

		if (!$style_definition || empty($style_definition['directive'])) {
			wp_send_json_error(array('message' => 'Smart Tone style definition could not be found.'));
		}

		$style_prompt = $style_definition['directive'];
		$final_prompt = $config['base_instruction'] . "\n\n" . $style_prompt;
		if ($voice_direction !== '') {
			$final_prompt .= "\n\nVoice Direction:\n" . $voice_direction;
		}
		$final_prompt .= "\n\nTarget Script:\n" . $text;

		// API呼び出し (Google AI Studio または Vertex AI)
		$provider = get_option('podcastmaker_api_provider', 'google_ai_studio');
		$api = null;
		$model = 'gemini-2.5-flash';

		if ($provider === 'vertex_ai') {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
			$vertex_json = get_option('podcastmaker_vertex_auth_json', '');
			$vertex_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_location', 'global'));
				$model = $this->normalize_text_model(get_option('podcastmaker_vertex_model', 'gemini-2.5-flash'), 'gemini-2.5-flash');
			$resolved_location = $this->resolve_vertex_location_for_model($model, 'text', $vertex_location, 'us-central1');
			if (is_wp_error($resolved_location)) {
				wp_send_json_error(array('message' => $resolved_location->get_error_message()));
			}
			$api = new Podcastmaker_API_Vertex($vertex_json, $resolved_location);
		}
		else {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
			$api = new Podcastmaker_API_Gemini(get_option('Podcastmaker_gemini_api_key'));
			$model = $this->normalize_text_model(get_option('Podcastmaker_gemini_model', 'gemini-2.5-flash'), 'gemini-2.5-flash');
		}

		$result = $api->generate_content($model, $final_prompt);

		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()));
		}

		wp_send_json_success(array('text' => $result));
	}

	/**
	 * Normalize text-generation model IDs so old saved options never call legacy models.
	 */
	/**
	 * Normalize the requested script mode.
	 *
	 * @param string $mode The requested mode.
	 * @return string
	 */
	private function normalize_requested_script_mode($mode)
	{
		$mode = is_string($mode) ? trim(strtolower($mode)) : 'single';
		return $mode === 'multi' ? 'multi' : 'single';
	}

	/**
	 * Get the saved script mode for a script post.
	 *
	 * @param int $post_id The script post ID.
	 * @return string
	 */
	private function get_script_mode_for_post($post_id)
	{
		$saved_mode = $this->normalize_requested_script_mode(get_post_meta($post_id, '_Podcastmaker_script_mode', true));
		if ($saved_mode === 'multi') {
			return 'multi';
		}

		$saved_map = $this->get_saved_speaker_map_for_post($post_id);
		return !empty($saved_map) ? 'multi' : 'single';
	}

	/**
	 * Get the saved multi-speaker map for a script post.
	 *
	 * @param int $post_id The script post ID.
	 * @return array<string, string>
	 */
	private function get_saved_speaker_map_for_post($post_id)
	{
		$raw_value = get_post_meta($post_id, '_Podcastmaker_multi_speaker_map', true);
		return $this->sanitize_speaker_map_json_payload($raw_value);
	}

	/**
	 * Build the voice summary label for a script post.
	 *
	 * @param int $post_id The script post ID.
	 * @return string
	 */
	private function get_voice_summary_label_for_post($post_id)
	{
		$summary_label = get_post_meta($post_id, '_Podcastmaker_voice_summary_label', true);
		if (!empty($summary_label)) {
			return (string) $summary_label;
		}

		$script_mode = $this->get_script_mode_for_post($post_id);
		if ($script_mode === 'multi') {
			$speaker_map = $this->get_saved_speaker_map_for_post($post_id);
			return $this->build_voice_summary_label('multi', '', $speaker_map, array_keys($speaker_map));
		}

		return (string) get_post_meta($post_id, '_Podcastmaker_voice_id', true);
	}

	/**
	 * Normalize script HTML/text into plain text while preserving line breaks.
	 *
	 * @param string $script_content The original script content.
	 * @return string
	 */
	private function normalize_script_plain_text($script_content)
	{
		$script_content = is_string($script_content) ? trim($script_content) : '';
		if ($script_content === '') {
			return '';
		}

		$script_content = preg_replace('/<\/p>\s*<p>/i', "\n\n", $script_content);
		$script_content = preg_replace('/<br\s*\/?>/i', "\n", $script_content);
		$plain_text = wp_strip_all_tags($script_content);
		$plain_text = preg_replace("/\r\n|\r/u", "\n", $plain_text);
		$plain_text = preg_replace("/\n{3,}/u", "\n\n", $plain_text);
		return trim((string) $plain_text);
	}

	/**
	 * Sanitize the multi-speaker voice map payload.
	 *
	 * @param mixed $speaker_map_json The raw JSON string or array payload.
	 * @return array<string, string>
	 */
	private function sanitize_speaker_map_json_payload($speaker_map_json)
	{
		if (is_array($speaker_map_json)) {
			$decoded_map = $speaker_map_json;
		}
		elseif (is_string($speaker_map_json) && $speaker_map_json !== '') {
			$decoded_map = json_decode($speaker_map_json, true);
		}
		else {
			$decoded_map = array();
		}

		if (!is_array($decoded_map)) {
			return array();
		}

		$sanitized_map = array();
		foreach ($decoded_map as $speaker_name => $voice_name) {
			$normalized_speaker_name = $this->normalize_speaker_name_key($speaker_name);
			$normalized_voice_name = sanitize_text_field((string) $voice_name);
			if ($normalized_speaker_name === '' || $normalized_voice_name === '') {
				continue;
			}
			$sanitized_map[$normalized_speaker_name] = $normalized_voice_name;
		}

		return $sanitized_map;
	}

	/**
	 * Build a generation-ready speaker map using the original speaker labels.
	 *
	 * @param array<int, string> $detected_speakers The detected speaker labels.
	 * @param array<string, string> $speaker_map The normalized speaker map.
	 * @return array<string, string>
	 */
	private function build_generation_speaker_map($detected_speakers, $speaker_map)
	{
		$generation_map = array();
		$detected_speakers = is_array($detected_speakers) ? $detected_speakers : array();
		$speaker_map = is_array($speaker_map) ? $speaker_map : array();

		foreach ($detected_speakers as $speaker_name) {
			$original_speaker_name = $this->sanitize_speaker_name($speaker_name);
			$mapped_voice_name = $this->get_speaker_map_value_case_insensitive($speaker_map, $speaker_name);
			if ($original_speaker_name === '' || $mapped_voice_name === '') {
				continue;
			}

			$generation_map[$original_speaker_name] = $mapped_voice_name;
		}

		return $generation_map;
	}

	/**
	 * Resolve a speaker map value using case-insensitive key matching.
	 *
	 * @param array<string, string> $speaker_map The speaker map.
	 * @param string $speaker_name The speaker name.
	 * @return string
	 */
	private function get_speaker_map_value_case_insensitive($speaker_map, $speaker_name)
	{
		$speaker_map = is_array($speaker_map) ? $speaker_map : array();
		$normalized_speaker_name = $this->normalize_speaker_name_key($speaker_name);
		if ($normalized_speaker_name === '') {
			return '';
		}

		foreach ($speaker_map as $map_key => $map_value) {
			if ($this->normalize_speaker_name_key($map_key) === $normalized_speaker_name) {
				return trim((string) $map_value);
			}
		}

		return '';
	}

	/**
	 * Sanitize a speaker name while preserving Unicode letters.
	 *
	 * @param string $speaker_name The raw speaker name.
	 * @return string
	 */
	private function sanitize_speaker_name($speaker_name)
	{
		$speaker_name = sanitize_text_field((string) $speaker_name);
		$speaker_name = preg_replace('/[:：]+/u', '', $speaker_name);
		$speaker_name = preg_replace('/\s+/u', ' ', (string) $speaker_name);
		return trim((string) $speaker_name);
	}

	/**
	 * Normalize a speaker name for array keys.
	 *
	 * @param string $speaker_name The raw speaker name.
	 * @return string
	 */
	private function normalize_speaker_name_key($speaker_name)
	{
		return strtolower($this->sanitize_speaker_name($speaker_name));
	}

	/**
	 * Build the display label for the selected voice configuration.
	 *
	 * @param string $script_mode The script mode.
	 * @param string $voice_id The single speaker voice.
	 * @param array<string, string> $speaker_map The speaker map.
	 * @param string[] $speaker_order The detected speaker order.
	 * @return string
	 */
	private function build_voice_summary_label($script_mode, $voice_id, $speaker_map, $speaker_order)
	{
		if ($this->normalize_requested_script_mode($script_mode) !== 'multi') {
			return sanitize_text_field((string) $voice_id);
		}

		$summary_parts = array();
		$ordered_speakers = !empty($speaker_order) ? $speaker_order : array_keys($speaker_map);
		foreach ($ordered_speakers as $speaker_name) {
			$display_speaker_name = $this->sanitize_speaker_name($speaker_name);
			$mapped_voice_name = $this->get_speaker_map_value_case_insensitive($speaker_map, $speaker_name);
			if ($mapped_voice_name === '') {
				continue;
			}

			$summary_parts[] = sprintf('%s: %s', $display_speaker_name, sanitize_text_field($mapped_voice_name));
		}

		return implode(' / ', $summary_parts);
	}

	/**
	 * Determine the effective TTS provider for the current request.
	 *
	 * @param string $script_mode The requested script mode.
	 * @return string
	 */
	private function get_effective_tts_provider($script_mode)
	{
		$configured_provider = get_option('podcastmaker_api_provider', 'google_ai_studio');
		if ($configured_provider === 'vertex_ai' && Podcastmaker_License::is_business()) {
			return 'vertex_ai';
		}

		if ($this->normalize_requested_script_mode($script_mode) === 'multi' && Podcastmaker_License::is_pro()) {
			return 'google_ai_studio';
		}

		return 'google_ai_studio';
	}

	/**
	 * Generate audio for a single-speaker script.
	 *
	 * @param string $provider The active provider.
	 * @param string $tts_model The requested TTS model.
	 * @param string $clean_text The plain text script.
	 * @param string $voice_id The selected voice ID.
	 * @param string $voice_direction The voice direction text.
	 * @return string|WP_Error
	 */
	private function generate_single_speaker_audio_data($provider, $tts_model, $clean_text, $voice_id, $voice_direction = '')
	{
		if ($provider === 'vertex_ai') {
			if (!class_exists('Podcastmaker_API_Vertex')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
			}

			$vertex_json = get_option('podcastmaker_vertex_auth_json', '');
			$vertex_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_location', 'global'));
			$vertex_tts_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_tts_location', $vertex_location));
			if (empty($vertex_json)) {
				return new WP_Error('missing_vertex_credentials', 'Vertex AI: Service Account JSON is not configured.');
			}

			$vertex_tts_model = !empty($tts_model) ? $tts_model : get_option('podcastmaker_vertex_tts_model', 'gemini-2.5-flash-tts');
			$vertex_tts_model = $this->normalize_vertex_tts_model($vertex_tts_model);
			$resolved_tts_location = $this->resolve_vertex_location_for_model($vertex_tts_model, 'tts', $vertex_tts_location, $vertex_location);
			if (is_wp_error($resolved_tts_location)) {
				return $resolved_tts_location;
			}
			$api = new Podcastmaker_API_Vertex($vertex_json, $vertex_location, $resolved_tts_location);
			return $api->generate_speech($clean_text, $voice_id, $vertex_tts_model, $voice_direction);
		}

		$api_key = get_option('Podcastmaker_gemini_tts_api_key');
		if (empty($api_key)) {
			return new WP_Error('missing_gemini_tts_key', 'Gemini Voice API Key is missing. Please check settings.');
		}

		if (!class_exists('Podcastmaker_API_Gemini')) {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
		}

		$gemini_api = new Podcastmaker_API_Gemini($api_key);
		$normalized_tts_model = $this->normalize_gemini_tts_model($tts_model);
		return $gemini_api->generate_speech($clean_text, $voice_id, $normalized_tts_model, $voice_direction);
	}

	/**
	 * Generate audio for a multi-speaker script.
	 *
	 * @param string $provider The active provider.
	 * @param string $tts_model The requested TTS model.
	 * @param array<int, array<string, string>> $turns The parsed dialogue turns.
	 * @param array<string, string> $speaker_map The voice mapping per speaker.
	 * @return string|WP_Error
	 */
	private function generate_multi_speaker_audio_data($provider, $tts_model, $turns, $speaker_map, $voice_direction = '')
	{
		$normalized_tts_payload = $this->normalize_multi_speaker_tts_payload($turns, $speaker_map, $voice_direction);
		$normalized_turns = $normalized_tts_payload['turns'];
		$normalized_speaker_map = $normalized_tts_payload['speaker_map'];
		$generation_speaker_map = isset($normalized_tts_payload['generation_speaker_map']) && is_array($normalized_tts_payload['generation_speaker_map'])
			? $normalized_tts_payload['generation_speaker_map']
			: $normalized_speaker_map;
		$normalized_voice_direction = $normalized_tts_payload['voice_direction'];

		if ($provider === 'vertex_ai') {
			if (!class_exists('Podcastmaker_API_Vertex')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
			}

			$vertex_json = get_option('podcastmaker_vertex_auth_json', '');
			$vertex_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_location', 'global'));
			$vertex_tts_location = $this->normalize_vertex_location(get_option('podcastmaker_vertex_tts_location', $vertex_location));
			if (empty($vertex_json)) {
				return new WP_Error('missing_vertex_credentials', 'Vertex AI: Service Account JSON is not configured.');
			}

			$vertex_tts_model = !empty($tts_model) ? $tts_model : get_option('podcastmaker_vertex_tts_model', 'gemini-2.5-flash-tts');
			$vertex_tts_model = $this->normalize_vertex_tts_model($vertex_tts_model);
			$resolved_tts_location = $this->resolve_vertex_location_for_model($vertex_tts_model, 'tts', $vertex_tts_location, $vertex_location);
			if (is_wp_error($resolved_tts_location)) {
				return $resolved_tts_location;
			}
			$api = new Podcastmaker_API_Vertex($vertex_json, $vertex_location, $resolved_tts_location);
			return $api->generate_multi_speaker_speech($normalized_turns, $generation_speaker_map, $vertex_tts_model, $normalized_voice_direction);
		}

		$api_key = get_option('Podcastmaker_gemini_tts_api_key');
		if (empty($api_key)) {
			return new WP_Error('missing_gemini_tts_key', 'Gemini Voice API Key is missing. Please check settings.');
		}

		if (!class_exists('Podcastmaker_API_Gemini')) {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
		}

		$gemini_api = new Podcastmaker_API_Gemini($api_key);
		$normalized_tts_model = $this->normalize_gemini_tts_model($tts_model);
		return $gemini_api->generate_multi_speaker_speech($normalized_turns, $generation_speaker_map, $normalized_tts_model, $normalized_voice_direction);
	}

	/**
	 * Normalize multi-speaker TTS payloads using stable internal speaker IDs.
	 *
	 * @param array<int, array<string, string>> $turns Parsed dialogue turns.
	 * @param array<string, string>             $speaker_map User-visible speaker map.
	 * @param string                            $voice_direction User-provided voice direction.
	 * @return array{turns: array<int, array<string, string>>, speaker_map: array<string, string>, voice_direction: string}
	 */
	private function normalize_multi_speaker_tts_payload($turns, $speaker_map, $voice_direction)
	{
		$normalized_turns = array();
		$ordered_speakers = array();

		foreach ($turns as $turn) {
			$original_speaker_name = isset($turn['speaker']) ? $this->sanitize_speaker_name($turn['speaker']) : '';
			$normalized_turn_text = isset($turn['text']) ? trim((string) $turn['text']) : '';
			if ($original_speaker_name === '' || $normalized_turn_text === '') {
				continue;
			}

			$normalized_turns[] = array(
				'speaker' => $original_speaker_name,
				'text' => $normalized_turn_text,
			);

			if (!in_array($original_speaker_name, $ordered_speakers, true)) {
				$ordered_speakers[] = $original_speaker_name;
			}
		}

		$stored_speaker_map = array();
		$generation_speaker_map = array();
		foreach ($speaker_map as $speaker_name => $voice_name) {
			$original_speaker_name = $this->sanitize_speaker_name($speaker_name);
			$mapped_voice_name = trim((string) $voice_name);
			if ($original_speaker_name === '' || $mapped_voice_name === '') {
				continue;
			}

			$stored_speaker_map[$this->normalize_speaker_name_key($original_speaker_name)] = $mapped_voice_name;
			$generation_speaker_map[$original_speaker_name] = $mapped_voice_name;
		}

		$normalized_voice_direction = trim((string) $voice_direction);
		$role_mapping_lines = array();
		foreach (array_keys($generation_speaker_map) as $original_speaker_name) {
			$role_mapping_lines[] = sprintf('- %s corresponds to the configured voice for "%s".', $original_speaker_name, $original_speaker_name);
		}

		if (!empty($role_mapping_lines)) {
			$role_mapping_block = "Speaker Role Mapping:\n" . implode("\n", $role_mapping_lines);
			if ($normalized_voice_direction !== '') {
				$normalized_voice_direction .= "\n\n" . $role_mapping_block;
			}
			else {
				$normalized_voice_direction = $role_mapping_block;
			}
		}

		return array(
			'turns' => $normalized_turns,
			'speaker_map' => $stored_speaker_map,
			'generation_speaker_map' => $generation_speaker_map,
			'voice_direction' => $normalized_voice_direction,
		);
	}

	/**
	 * Get the maximum bytes allowed per multi-speaker prompt payload.
	 *
	 * @return int
	 */
	private function get_multi_speaker_chunk_max_total_bytes()
	{
		return 3200;
	}

	/**
	 * Build a chunk plan for multi-speaker TTS while repeating the voice direction in every chunk.
	 *
	 * @param array<int, array<string, string>> $turns Parsed turns.
	 * @param string                            $voice_direction The voice direction text.
	 * @return array<int, array<string, mixed>>
	 */
	private function build_multi_speaker_chunk_plan($turns, $voice_direction)
	{
		$chunks = array();
		$current_chunk = array();
		$max_total_bytes = $this->get_multi_speaker_chunk_max_total_bytes();

		foreach ($turns as $turn) {
			$speaker_name = isset($turn['speaker']) ? trim((string) $turn['speaker']) : '';
			$turn_text = isset($turn['text']) ? trim((string) $turn['text']) : '';
			if ($speaker_name === '' || $turn_text === '') {
				continue;
			}

			$normalized_turn = array(
				'turn' => isset($turn['turn']) ? absint($turn['turn']) : 0,
				'speaker' => $speaker_name,
				'text' => $turn_text,
			);

			$single_turn_payload_bytes = $this->estimate_multi_speaker_prompt_bytes(array($normalized_turn), $voice_direction);
			if ($single_turn_payload_bytes > $max_total_bytes) {
				if (!empty($current_chunk)) {
					$chunks[] = $this->build_multi_speaker_chunk_entry($current_chunk, $voice_direction);
					$current_chunk = array();
				}

				foreach ($this->split_multi_speaker_turn_for_budget($speaker_name, $turn_text, $voice_direction) as $split_turn) {
					$chunks[] = $this->build_multi_speaker_chunk_entry(array($split_turn), $voice_direction);
				}
				continue;
			}

			$candidate_chunk = $current_chunk;
			$candidate_chunk[] = $normalized_turn;
			if ($this->estimate_multi_speaker_prompt_bytes($candidate_chunk, $voice_direction) > $max_total_bytes) {
				if (!empty($current_chunk)) {
					$chunks[] = $this->build_multi_speaker_chunk_entry($current_chunk, $voice_direction);
				}
				$current_chunk = array($normalized_turn);
				continue;
			}

			$current_chunk[] = $normalized_turn;
		}

		if (!empty($current_chunk)) {
			$chunks[] = $this->build_multi_speaker_chunk_entry($current_chunk, $voice_direction);
		}

		return $chunks;
	}

	/**
	 * Build one chunk entry with prompt bytes metadata.
	 *
	 * @param array<int, array<string, string|int>> $turns Chunk turns.
	 * @param string                                $voice_direction Voice direction text.
	 * @return array<string, mixed>
	 */
	private function build_multi_speaker_chunk_entry($turns, $voice_direction)
	{
		return array(
			'turns' => $turns,
			'payload_bytes' => $this->estimate_multi_speaker_prompt_bytes($turns, $voice_direction),
		);
	}

	/**
	 * Estimate prompt bytes for a voice-direction aware multi-speaker prompt.
	 *
	 * @param array<int, array<string, string|int>> $turns Parsed turns.
	 * @param string                                $voice_direction Voice direction text.
	 * @return int
	 */
	private function estimate_multi_speaker_prompt_bytes($turns, $voice_direction)
	{
		$speaker_names = array();
		$lines = array();
		foreach ($turns as $turn) {
			$speaker_name = isset($turn['speaker']) ? trim((string) $turn['speaker']) : '';
			$turn_text = isset($turn['text']) ? trim((string) $turn['text']) : '';
			if ($speaker_name === '' || $turn_text === '') {
				continue;
			}
			if (!in_array($speaker_name, $speaker_names, true)) {
				$speaker_names[] = $speaker_name;
			}
			$lines[] = $speaker_name . ': ' . $turn_text;
		}

		$voice_direction_block = '';
		$voice_direction = is_string($voice_direction) ? trim($voice_direction) : '';
		if ($voice_direction !== '') {
			$voice_direction_block = "Voice Direction:\n" . $voice_direction . "\n\n";
		}

		$prompt = $voice_direction_block . 'TTS the following conversation between ' . implode(' and ', $speaker_names) . ":\n" . implode("\n", $lines);
		return strlen($prompt);
	}

	/**
	 * Split an oversized single turn so it can fit within the current chunk budget.
	 *
	 * @param string $speaker_name Speaker name.
	 * @param string $turn_text Turn text.
	 * @param string $voice_direction Voice direction text.
	 * @return array<int, array<string, string|int>>
	 */
	private function split_multi_speaker_turn_for_budget($speaker_name, $turn_text, $voice_direction)
	{
		$voice_direction = is_string($voice_direction) ? trim($voice_direction) : '';
		$budget = max(600, $this->get_multi_speaker_chunk_max_total_bytes() - $this->estimate_multi_speaker_prompt_bytes(array(), $voice_direction) - 32);
		$segments = array();
		$offset = 0;
		$text_bytes = strlen($turn_text);

		while ($offset < $text_bytes) {
			$segment_text = trim((string) mb_strcut($turn_text, $offset, $budget, 'UTF-8'));
			if ($segment_text === '') {
				break;
			}

			$segments[] = array(
				'turn' => 0,
				'speaker' => $speaker_name,
				'text' => $segment_text,
			);
			$offset += strlen($segment_text);
		}

		return $segments;
	}

	/**
	 * Save a multi-speaker generation session.
	 *
	 * @param string               $session_id The session ID.
	 * @param array<string, mixed> $session_payload The payload to persist.
	 * @return void
	 */
	private function save_multi_speaker_session($session_id, $session_payload)
	{
		set_transient($this->get_multi_speaker_session_key($session_id), $session_payload, HOUR_IN_SECONDS);
	}

	/**
	 * Load a multi-speaker generation session.
	 *
	 * @param string $session_id The session ID.
	 * @return array<string, mixed>|false
	 */
	private function get_multi_speaker_session($session_id)
	{
		$session_id = sanitize_key($session_id);
		if ($session_id === '') {
			return false;
		}

		$payload = get_transient($this->get_multi_speaker_session_key($session_id));
		return is_array($payload) ? $payload : false;
	}

	/**
	 * Delete a multi-speaker generation session.
	 *
	 * @param string $session_id The session ID.
	 * @return void
	 */
	private function delete_multi_speaker_session($session_id)
	{
		$payload = $this->get_multi_speaker_session($session_id);
		if (is_array($payload) && !empty($payload['chunk_file_paths']) && is_array($payload['chunk_file_paths'])) {
			foreach ($payload['chunk_file_paths'] as $chunk_file_path) {
				if (is_string($chunk_file_path) && $chunk_file_path !== '' && file_exists($chunk_file_path)) {
					wp_delete_file($chunk_file_path);
				}
			}
		}

		delete_transient($this->get_multi_speaker_session_key($session_id));
	}

	/**
	 * Build the transient key for a multi-speaker generation session.
	 *
	 * @param string $session_id The session ID.
	 * @return string
	 */
	private function get_multi_speaker_session_key($session_id)
	{
		return 'podcastmaker_multi_tts_' . sanitize_key($session_id);
	}

	/**
	 * Extract PCM data from a generated WAV file.
	 *
	 * @param string $wav_data The generated WAV bytes.
	 * @return string
	 */
	private function extract_pcm_from_wav($wav_data)
	{
		$wav_data = is_string($wav_data) ? $wav_data : '';
		if ($wav_data === '') {
			return '';
		}

		if (substr($wav_data, 0, 4) === 'RIFF' && strlen($wav_data) > 44) {
			return substr($wav_data, 44);
		}

		return $wav_data;
	}

	/**
	 * Add a standard WAV header to PCM data.
	 *
	 * @param string $pcm_data Raw PCM bytes.
	 * @param int    $sample_rate Sample rate.
	 * @param int    $channels Number of channels.
	 * @param int    $bits_per_sample Bits per sample.
	 * @return string
	 */
	private function add_wav_header($pcm_data, $sample_rate = 24000, $channels = 1, $bits_per_sample = 16)
	{
		return $this->build_wav_header(strlen($pcm_data), $sample_rate, $channels, $bits_per_sample) . $pcm_data;
	}

	/**
	 * Build a standard WAV header for a PCM byte length.
	 *
	 * @param int $data_len PCM byte length.
	 * @param int $sample_rate Sample rate.
	 * @param int $channels Number of channels.
	 * @param int $bits_per_sample Bits per sample.
	 * @return string
	 */
	private function build_wav_header($data_len, $sample_rate = 24000, $channels = 1, $bits_per_sample = 16)
	{
		$data_len = max(0, (int) $data_len);
		$block_align = $channels * (int) ($bits_per_sample / 8);
		$byte_rate = $sample_rate * $block_align;
		$riff_chunk_size = 36 + $data_len;

		$header = '';
		$header .= 'RIFF';
		$header .= pack('V', $riff_chunk_size);
		$header .= 'WAVE';
		$header .= 'fmt ';
		$header .= pack('V', 16);
		$header .= pack('v', 1);
		$header .= pack('v', $channels);
		$header .= pack('V', $sample_rate);
		$header .= pack('V', $byte_rate);
		$header .= pack('v', $block_align);
		$header .= pack('v', $bits_per_sample);
		$header .= 'data';
		$header .= pack('V', $data_len);

		return $header;
	}

	/**
	 * Persist a chunk PCM payload to a temporary file for the current multi-speaker session.
	 *
	 * @param string $session_id Session ID.
	 * @param int    $chunk_index Chunk index.
	 * @param string $pcm_data PCM bytes.
	 * @return string|WP_Error
	 */
	private function write_multi_speaker_chunk_file($session_id, $chunk_index, $pcm_data)
	{
		$temp_dir = $this->get_multi_speaker_temp_directory();
		if (is_wp_error($temp_dir)) {
			return $temp_dir;
		}

		$filename = sprintf('multi-tts-%s-%d.pcm', sanitize_key($session_id), absint($chunk_index));
		$file_path = trailingslashit($temp_dir) . $filename;
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$bytes_written = file_put_contents($file_path, $pcm_data);
		if ($bytes_written === false) {
			return new WP_Error('chunk_write_error', 'Failed to write the multi-speaker chunk file to disk.');
		}

		return $file_path;
	}

	/**
	 * Resolve the temporary directory used for multi-speaker PCM chunks.
	 *
	 * @return string|WP_Error
	 */
	private function get_multi_speaker_temp_directory()
	{
		$upload_dir = wp_upload_dir();
		if (!empty($upload_dir['error'])) {
			return new WP_Error('upload_dir_error', $upload_dir['error']);
		}

		$temp_dir = trailingslashit($upload_dir['basedir']) . 'podcastmaker-multi-tts';
		if (!wp_mkdir_p($temp_dir)) {
			return new WP_Error('mkdir_error', 'Failed to create the temporary directory for multi-speaker TTS chunks.');
		}

		return $temp_dir;
	}

	/**
	 * Create a destination path for a generated audio file.
	 *
	 * @param string $file_ext File extension.
	 * @return array<string, string>|WP_Error
	 */
	private function create_generated_audio_file_path($file_ext)
	{
		$upload_dir = wp_upload_dir();
		if (!empty($upload_dir['error'])) {
			return new WP_Error('upload_dir_error', $upload_dir['error']);
		}

		$filename = 'podcast_audio_' . time() . '.' . $file_ext;
		return array(
			'filename' => $filename,
			'file_path' => trailingslashit($upload_dir['path']) . $filename,
			'file_url' => trailingslashit($upload_dir['url']) . $filename,
		);
	}

	/**
	 * Persist generated audio to uploads, attachment metadata, and analytics.
	 *
	 * @param string $audio_data The generated WAV data.
	 * @param string $script_mode The script mode.
	 * @param int    $post_id The script post ID.
	 * @param string $stored_voice_id The single speaker voice ID.
	 * @param string $voice_summary_label The summary label.
	 * @param string $stored_speaker_map_json The stored speaker map JSON.
	 * @param int    $speaker_count Number of speakers.
	 * @return array<string, mixed>|WP_Error
	 */
	private function save_generated_audio_file($audio_data, $script_mode, $post_id, $stored_voice_id, $voice_summary_label, $stored_speaker_map_json, $speaker_count)
	{
		if (empty($audio_data)) {
			return new WP_Error('empty_audio', 'Generated audio data is empty.');
		}

		$destination = $this->create_generated_audio_file_path('wav');
		if (is_wp_error($destination)) {
			return $destination;
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		if (file_put_contents($destination['file_path'], $audio_data) === false) {
			return new WP_Error('file_save_error', 'Failed to save audio file to server.');
		}

		return $this->persist_generated_audio_file(
			$destination['filename'],
			$destination['file_path'],
			$destination['file_url'],
			$script_mode,
			$post_id,
			$stored_voice_id,
			$voice_summary_label,
			$stored_speaker_map_json,
			$speaker_count
		);
	}

	/**
	 * Persist a generated audio file path to WordPress attachments, post meta, and analytics.
	 *
	 * @param string $filename The saved filename.
	 * @param string $file_path The saved file path.
	 * @param string $file_url The public file URL.
	 * @param string $script_mode The script mode.
	 * @param int    $post_id The script post ID.
	 * @param string $stored_voice_id The single speaker voice ID.
	 * @param string $voice_summary_label The summary label.
	 * @param string $stored_speaker_map_json The stored speaker map JSON.
	 * @param int    $speaker_count Number of speakers.
	 * @return array<string, mixed>|WP_Error
	 */
	private function persist_generated_audio_file($filename, $file_path, $file_url, $script_mode, $post_id, $stored_voice_id, $voice_summary_label, $stored_speaker_map_json, $speaker_count)
	{

		$file_type = wp_check_filetype($filename, null);
		$attachment = array(
			'post_mime_type' => $file_type['type'],
			'post_title' => 'Podcast Audio - ' . gmdate('Y-m-d H:i:s'),
			'post_content' => '',
			'post_status' => 'inherit',
		);

		$attach_id = wp_insert_attachment($attachment, $file_path, $post_id);
		if (!is_wp_error($attach_id)) {
			require_once(ABSPATH . 'wp-admin/includes/image.php');
			$attach_data = wp_generate_attachment_metadata($attach_id, $file_path);
			wp_update_attachment_metadata($attach_id, $attach_data);
		}

		if ($post_id > 0) {
			update_post_meta($post_id, '_Podcastmaker_audio_url', $file_url);
			update_post_meta($post_id, '_Podcastmaker_audio_id', $attach_id);
			update_post_meta($post_id, '_Podcastmaker_script_mode', $script_mode);
			update_post_meta($post_id, '_Podcastmaker_voice_id', $stored_voice_id);
			update_post_meta($post_id, '_Podcastmaker_voice_summary_label', $voice_summary_label);
			update_post_meta($post_id, '_Podcastmaker_multi_speaker_map', $stored_speaker_map_json);
		}

		if (class_exists('Podcastmaker_Audio_Analytics')) {
			$source_post_id = $post_id > 0 ? absint(get_post_meta($post_id, '_Podcastmaker_source_post_id', true)) : 0;
			Podcastmaker_Audio_Analytics::register_audio_library_entry(array(
				'audio_url' => $file_url,
				'attachment_id' => $attach_id,
				'script_post_id' => $post_id,
				'source_post_id' => $source_post_id,
				'audio_mode' => $script_mode,
				'voice_id' => $stored_voice_id,
				'voice_label' => $stored_voice_id,
				'voice_summary_label' => $voice_summary_label,
				'speaker_map_json' => $stored_speaker_map_json,
				'speaker_count' => $speaker_count,
			));
		}

		return array(
			'audio_url' => $file_url,
			'attach_id' => $attach_id,
			'script_mode' => $script_mode,
			'voice_summary_label' => $voice_summary_label,
			'speaker_map_json' => $stored_speaker_map_json,
		);
	}

	/**
	 * Parse the script into structured multi-speaker turns using a fixed parser model.
	 *
	 * @param string $script_content The raw script content.
	 * @param string $provider The active provider.
	 * @return array<int, array<string, string>>|WP_Error
	 */
	private function parse_multi_speaker_turns_with_parser_model($script_content, $provider)
	{
		$plain_text = $this->normalize_script_plain_text($script_content);
		$fallback_turns = $this->extract_turns_with_regex($plain_text);

		// Respect explicit speaker labels from the editor before asking another model to infer them.
		if (count($this->get_unique_speakers_from_turns($fallback_turns)) >= 2) {
			return $fallback_turns;
		}

		$parser_prompt = $this->build_multi_speaker_parser_prompt($plain_text);

		if ($provider === 'vertex_ai') {
			if (!class_exists('Podcastmaker_API_Vertex')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-vertex.php';
			}

			$vertex_json = get_option('podcastmaker_vertex_auth_json', '');
			if (empty($vertex_json)) {
				return new WP_Error('missing_vertex_credentials', 'Vertex AI: Service Account JSON is not configured.');
			}

			$vertex_api = new Podcastmaker_API_Vertex($vertex_json, 'global');
			$parser_response = $vertex_api->generate_content('gemini-3.1-pro-preview', $parser_prompt);
		}
		else {
			$api_key = get_option('Podcastmaker_gemini_api_key');
			if (empty($api_key)) {
				return new WP_Error('missing_gemini_key', 'Gemini API Key is missing in settings.');
			}

			if (!class_exists('Podcastmaker_API_Gemini')) {
				require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-api-gemini.php';
			}

			$gemini_api = new Podcastmaker_API_Gemini($api_key);
			$parser_response = $gemini_api->generate_content('gemini-2.5-flash-lite', $parser_prompt);
		}

		if (is_wp_error($parser_response)) {
			return !empty($fallback_turns) ? $fallback_turns : $parser_response;
		}

		$decoded_turns = $this->decode_dialogue_json_payload($parser_response);
		if (empty($decoded_turns)) {
			return !empty($fallback_turns) ? $fallback_turns : new WP_Error('invalid_dialogue_json', 'Failed to structure the multi-speaker script into JSON turns.');
		}

		return $decoded_turns;
	}

	/**
	 * Create the fixed parser prompt used to structure dialogue turns.
	 *
	 * @param string $plain_text The plain text script.
	 * @return string
	 */
	private function build_multi_speaker_parser_prompt($plain_text)
	{
		return "You are a dialogue parser for podcast TTS.\n"
			. "Return ONLY a raw JSON array. Do not wrap it in markdown.\n"
			. "Each item must be an object with exactly two keys: speaker, text.\n"
			. "Preserve the original dialogue wording exactly.\n"
			. "Combine unlabeled continuation lines into the previous speaker turn.\n"
			. "Ignore empty lines.\n"
			. "If a line starts with a speaker label using ':' or '：', use that label as the speaker name.\n"
			. "Output format example: [{\"speaker\":\"Host\",\"text\":\"Hello\"}]\n\n"
			. "SCRIPT:\n"
			. $plain_text;
	}

	/**
	 * Decode the JSON payload returned by the fixed parser model.
	 *
	 * @param string $raw_response The raw model response.
	 * @return array<int, array<string, string>>
	 */
	private function decode_dialogue_json_payload($raw_response)
	{
		$raw_response = trim((string) $raw_response);
		$raw_response = preg_replace('/^```(?:json)?\s*|\s*```$/u', '', $raw_response);
		$decoded = json_decode($raw_response, true);
		if (!is_array($decoded)) {
			return array();
		}

		$turns = array();
		foreach ($decoded as $turn) {
			if (!is_array($turn)) {
				continue;
			}

			$speaker_name = isset($turn['speaker']) ? $this->sanitize_speaker_name($turn['speaker']) : '';
			$turn_text = isset($turn['text']) ? trim((string) $turn['text']) : '';
			if ($speaker_name === '' || $turn_text === '') {
				continue;
			}

			$turns[] = array(
				'speaker' => $speaker_name,
				'text' => $turn_text,
			);
		}

		return $turns;
	}

	/**
	 * Extract turns with a deterministic regex fallback.
	 *
	 * @param string $plain_text The plain text script.
	 * @return array<int, array<string, string>>
	 */
	private function extract_turns_with_regex($plain_text)
	{
		$lines = preg_split('/\n+/u', (string) $plain_text);
		if (!is_array($lines)) {
			return array();
		}

		$turns = array();
		$current_speaker = '';
		$current_text = '';

		foreach ($lines as $line) {
			$trimmed_line = trim((string) $line);
			if ($trimmed_line === '') {
				continue;
			}

			if (preg_match('/^([^:：\n]{1,80})\s*[:：]\s*(.*)$/u', $trimmed_line, $matches)) {
				if ($current_speaker !== '' && trim($current_text) !== '') {
					$turns[] = array(
						'speaker' => $current_speaker,
						'text' => trim($current_text),
					);
				}

				$current_speaker = $this->sanitize_speaker_name($matches[1]);
				$current_text = isset($matches[2]) ? trim((string) $matches[2]) : '';
				continue;
			}

			if ($current_speaker !== '') {
				$current_text .= ($current_text === '' ? '' : "\n") . $trimmed_line;
			}
		}

		if ($current_speaker !== '' && trim($current_text) !== '') {
			$turns[] = array(
				'speaker' => $current_speaker,
				'text' => trim($current_text),
			);
		}

		return $turns;
	}

	/**
	 * Get unique speakers from parsed turns while preserving order.
	 *
	 * @param array<int, array<string, string>> $turns The parsed turns.
	 * @return string[]
	 */
	private function get_unique_speakers_from_turns($turns)
	{
		$unique_speakers = array();
		foreach ($turns as $turn) {
			$speaker_name = isset($turn['speaker']) ? $this->sanitize_speaker_name($turn['speaker']) : '';
			if ($speaker_name === '' || in_array($speaker_name, $unique_speakers, true)) {
				continue;
			}
			$unique_speakers[] = $speaker_name;
		}

		return $unique_speakers;
	}

	/**
	 * Normalize text model IDs saved by older plugin versions.
	 */
	private function normalize_text_model($model, $fallback = 'gemini-2.5-pro')
	{
		$model = is_string($model) ? trim($model) : '';
		if (empty($model) || !preg_match('/^[a-zA-Z0-9\-\.]+$/', $model)) {
			return $fallback;
		}

		$normalized = strtolower($model);
		if (strpos($normalized, 'tts') !== false) {
			return $fallback;
		}

		if ($normalized === 'gemini-3-pro-preview') {
			return 'gemini-3.1-pro-preview';
		}

		if (strpos($normalized, 'gemini-pro') === 0 || strpos($normalized, 'gemini-1.5-pro') === 0) {
			return 'gemini-2.5-pro';
		}

		if (strpos($normalized, 'gemini-2.0-flash-lite') === 0) {
			return 'gemini-2.5-flash-lite';
		}

		if (strpos($normalized, 'gemini-1.5-flash') === 0 || strpos($normalized, 'gemini-2.0-flash') === 0) {
			return 'gemini-2.5-flash';
		}

		return $normalized;
	}

	/**
	 * Normalize Google AI Studio TTS model IDs saved by older plugin versions.
	 */
	private function normalize_gemini_tts_model($model, $fallback = 'gemini-2.5-flash-preview-tts')
	{
		$model = is_string($model) ? trim($model) : '';
		if (empty($model) || !preg_match('/^[a-zA-Z0-9\-\.]+$/', $model)) {
			return $fallback;
		}

		$normalized = strtolower($model);
		$map = array(
			'gemini-3.1-flash-tts-preview' => 'gemini-3.1-flash-tts-preview',
			'gemini-2.5-flash' => 'gemini-2.5-flash-preview-tts',
			'gemini-2.5-flash-tts' => 'gemini-2.5-flash-preview-tts',
			'gemini-2.5-pro' => 'gemini-2.5-pro-preview-tts',
			'gemini-2.5-pro-tts' => 'gemini-2.5-pro-preview-tts',
		);

		if (isset($map[$normalized])) {
			return $map[$normalized];
		}

		if (strpos($normalized, 'gemini-1.5') === 0 || strpos($normalized, 'gemini-pro') === 0) {
			return $fallback;
		}

		return in_array($normalized, array('gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-preview-tts', 'gemini-2.5-pro-preview-tts'), true) ? $normalized : $fallback;
	}

	/**
	 * Normalize Vertex AI TTS model IDs saved by older plugin versions.
	 */
	private function normalize_vertex_tts_model($model, $fallback = 'gemini-2.5-flash-tts')
	{
		$model = is_string($model) ? trim($model) : '';
		if (empty($model) || !preg_match('/^[a-zA-Z0-9\-\.]+$/', $model)) {
			return $fallback;
		}

		$normalized = strtolower($model);
		$map = array(
			'gemini-3.1-flash-tts-preview' => 'gemini-3.1-flash-tts-preview',
			'gemini-2.5-flash' => 'gemini-2.5-flash-tts',
			'gemini-2.5-flash-preview-tts' => 'gemini-2.5-flash-tts',
			'gemini-2.5-flash-lite' => 'gemini-2.5-flash-lite-preview-tts',
			'gemini-2.5-flash-lite-tts' => 'gemini-2.5-flash-lite-preview-tts',
			'gemini-2.5-pro' => 'gemini-2.5-pro-tts',
			'gemini-2.5-pro-preview-tts' => 'gemini-2.5-pro-tts',
		);

		if (isset($map[$normalized])) {
			return $map[$normalized];
		}

		if (strpos($normalized, 'gemini-1.5') === 0 || strpos($normalized, 'gemini-pro') === 0) {
			return $fallback;
		}

		$allowed = array(
			'gemini-3.1-flash-tts-preview',
			'gemini-2.5-flash-tts',
			'gemini-2.5-flash-tts',
			'gemini-2.5-flash-lite-preview-tts',
			'gemini-2.5-pro-tts',
		);

		return in_array($normalized, $allowed, true) ? $normalized : $fallback;
	}

	/**
	 * Normalize model arrays and remove duplicates after legacy migrations.
	 */
	private function normalize_model_list($models, $fallback, $kind)
	{
		$models = is_array($models) ? $models : array();
		if (empty($models)) {
			$models = $fallback;
		}

		$normalized = array();
		foreach ($models as $model) {
			if ($kind === 'vertex_tts') {
				$model = $this->normalize_vertex_tts_model($model);
			}
			elseif ($kind === 'gemini_tts') {
				$model = $this->normalize_gemini_tts_model($model);
			}
			else {
				$model = $this->normalize_text_model($model, isset($fallback[0]) ? $fallback[0] : 'gemini-2.5-pro');
			}

			if (!in_array($model, $normalized, true)) {
				$normalized[] = $model;
			}
		}

		return !empty($normalized) ? $normalized : $fallback;
	}

	/**
	 * Resolve a selected model against the available model list.
	 *
	 * @param string $selected_model The selected model slug.
	 * @param array<int, string> $available_models Available model slugs.
	 * @param string $fallback_model Fallback model slug.
	 * @return string
	 */
	private function resolve_available_model($selected_model, $available_models, $fallback_model)
	{
		$selected_model = is_string($selected_model) ? trim($selected_model) : '';
		$fallback_model = is_string($fallback_model) ? trim($fallback_model) : '';
		$available_models = array_values(array_filter(array_map('strval', is_array($available_models) ? $available_models : array()), static function ($model) {
			return $model !== '';
		}));

		if ($selected_model !== '' && in_array($selected_model, $available_models, true)) {
			return $selected_model;
		}

		if (!empty($available_models)) {
			return $available_models[0];
		}

		return $fallback_model !== '' ? $fallback_model : $selected_model;
	}

	/**
	 * Normalize the Vertex AI endpoint location.
	 */
	private function normalize_vertex_location($location)
	{
		$location = is_string($location) ? trim($location) : '';
		$allowed_locations = array(
			'global',
			'us-central1',
			'us-east1',
			'us-east4',
			'us-east5',
			'us-south1',
			'us-west1',
			'us-west4',
			'northamerica-northeast1',
			'europe-central2',
			'europe-north1',
			'europe-southwest1',
			'europe-west1',
			'europe-west4',
			'asia-northeast1',
		);

		$normalized_location = sanitize_key($location);
		return in_array($normalized_location, $allowed_locations, true) ? $normalized_location : 'global';
	}

	/**
	 * Normalize the Vertex AI TTS endpoint location.
	 */
	private function normalize_vertex_tts_location($location)
	{
		return $this->normalize_vertex_location($location);
	}

	/**
	 * Get the allowed Vertex AI locations for a model kind.
	 *
	 * @param string $kind Model kind: text or tts.
	 * @return array<string, array<int, string>>
	 */
	private function get_vertex_model_region_map($kind)
	{
		$option_name = $kind === 'tts' ? 'podcastmaker_vertex_tts_model_regions' : 'podcastmaker_vertex_model_regions';
		$region_map = get_option($option_name, array());
		if (!is_array($region_map)) {
			return array();
		}

		$allowed_locations = array(
			'global',
			'us-central1',
			'us-east1',
			'us-east4',
			'us-east5',
			'us-south1',
			'us-west1',
			'us-west4',
			'northamerica-northeast1',
			'europe-central2',
			'europe-north1',
			'europe-southwest1',
			'europe-west1',
			'europe-west4',
			'asia-northeast1',
		);

		$sanitized_map = array();
		foreach ($region_map as $model_id => $locations) {
			$clean_model_id = sanitize_text_field((string) $model_id);
			if ($clean_model_id === '') {
				continue;
			}

			if (is_array($locations)) {
				$locations = reset($locations);
			}

			if (is_string($locations) || is_int($locations)) {
				$clean_location = sanitize_key((string) $locations);
				if (in_array($clean_location, $allowed_locations, true)) {
					$sanitized_map[$clean_model_id] = array($clean_location);
				}
				continue;
			}

			if (!is_array($locations)) {
				continue;
			}

			$sanitized_locations = array();
			foreach ($locations as $location) {
				$clean_location = sanitize_key((string) $location);
				if (in_array($clean_location, $allowed_locations, true)) {
					$sanitized_locations[] = $clean_location;
				}
			}

			$sanitized_locations = array_values(array_unique($sanitized_locations));
			if (!empty($sanitized_locations)) {
				$sanitized_map[$clean_model_id] = $sanitized_locations;
			}
		}

		return $sanitized_map;
	}

	/**
	 * Resolve the effective Vertex AI location for a given model.
	 *
	 * @param string $model The model ID.
	 * @param string $kind Model kind: text or tts.
	 * @param string $selected_location The selected location from settings.
	 * @param string $fallback_location The fallback location when the selection is invalid.
	 * @return string|WP_Error
	 */
	private function resolve_vertex_location_for_model($model, $kind, $selected_location, $fallback_location)
	{
		$normalized_model = sanitize_text_field((string) $model);
		$normalized_selected_location = $this->normalize_vertex_location($selected_location);
		$normalized_fallback_location = $this->normalize_vertex_location($fallback_location);
		$region_map = $this->get_vertex_model_region_map($kind);

		if (empty($region_map)) {
			return $normalized_selected_location !== '' ? $normalized_selected_location : $normalized_fallback_location;
		}

		if ($normalized_model === '' || !isset($region_map[$normalized_model])) {
			return $normalized_selected_location !== '' ? $normalized_selected_location : $normalized_fallback_location;
		}

		$allowed_locations = $region_map[$normalized_model];
		if (in_array($normalized_selected_location, $allowed_locations, true)) {
			return $normalized_selected_location;
		}

		if (in_array($normalized_fallback_location, $allowed_locations, true)) {
			return $normalized_fallback_location;
		}

		return !empty($allowed_locations) ? $allowed_locations[0] : $normalized_selected_location;
	}

	/**
	 * Smart Tone 用のスタイルリストを取得 (UI用)
	 */
	private function get_smart_tone_style_directives($for_ui = false)
	{
		if (!class_exists('Podcastmaker_Smart_Tone')) {
			require_once plugin_dir_path(__FILE__) . 'class-podcastmaker-smart-tone.php';
		}

		return Podcastmaker_Smart_Tone::get_style_options();
	}
}
