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

/**
 * Class Podcastmaker_API_Vertex
 *
 * Vertex AI (Google Cloud) との通信を処理するクラス。
 * サービスアカウントJSON → JWT → OAuth2アクセストークン → API呼び出し
 */
class Podcastmaker_API_Vertex
{

    /** @var array サービスアカウント情報 */
    private $service_account;

    /** @var string ロケーション (例: us-central1) */
    private $location;

    /** @var string TTS用ロケーション (例: us-central1) */
    private $tts_location;

    /** @var string|null キャッシュされたアクセストークン */
    private $access_token = null;

    /** @var int トークンの有効期限 (Unix timestamp) */
    private $token_expiry = 0;

    /**
     * コンストラクタ。
     *
     * @param string $sa_json サービスアカウントJSONテキスト。
     * @param string $location GCPリージョン。
     */
    public function __construct($sa_json, $location = 'us-central1', $tts_location = '')
    {
        $this->location = $location;
        $this->tts_location = !empty($tts_location) ? $tts_location : $location;

        if (is_string($sa_json) && !empty($sa_json)) {
            $this->service_account = json_decode($sa_json, true);
        }
        else {
            $this->service_account = null;
        }
    }

    /**
     * Base64URL エンコード (JWT用)。
     */
    private function base64url_encode($data)
    {
        return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
    }

    /**
     * OAuth2 アクセストークンを取得する。
     * JWT (JSON Web Token) を使用してサービスアカウント認証を行う。
     *
     * @return string|WP_Error アクセストークンまたはエラー。
     */
    public function get_access_token()
    {
        // キャッシュされたトークンが有効な場合はそれを返す
        if ($this->access_token && time() < $this->token_expiry - 60) {
            return $this->access_token;
        }

        if (empty($this->service_account) || empty($this->service_account['private_key'])) {
            return new WP_Error(
                'missing_credentials',
                __('Vertex AI: The service account JSON is invalid.', 'quikvox-ai'),
                array('details' => __('Required fields such as private_key or client_email are missing.', 'quikvox-ai'))
            );
        }

        $now = time();
        $expiry = $now + 3600; // 1時間有効

        // JWT Header
        $header = $this->base64url_encode(json_encode(array(
            'alg' => 'RS256',
            'typ' => 'JWT',
        )));

        // JWT Claim Set
        $claim = $this->base64url_encode(json_encode(array(
            'iss' => $this->service_account['client_email'],
            'scope' => 'https://www.googleapis.com/auth/cloud-platform',
            'aud' => 'https://oauth2.googleapis.com/token',
            'iat' => $now,
            'exp' => $expiry,
        )));

        // 署名
        $signature_input = $header . '.' . $claim;
        $private_key = openssl_pkey_get_private($this->service_account['private_key']);

        if (!$private_key) {
            return new WP_Error(
                'invalid_key',
                __('Vertex AI: The service account private key is invalid.', 'quikvox-ai'),
                array('details' => __('openssl_pkey_get_private() could not parse the key. Check line breaks and PEM headers.', 'quikvox-ai'))
            );
        }

        $signature = '';
        if (!openssl_sign($signature_input, $signature, $private_key, OPENSSL_ALGO_SHA256)) {
            return new WP_Error(
                'sign_failed',
                __('Vertex AI: Failed to sign the JWT.', 'quikvox-ai'),
                array('details' => __('The private key exists, but JWT signing failed.', 'quikvox-ai'))
            );
        }

        $jwt = $signature_input . '.' . $this->base64url_encode($signature);

        // トークンリクエスト
        $response = wp_remote_post('https://oauth2.googleapis.com/token', array(
            'body' => array(
                'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
                'assertion' => $jwt,
            ),
            'timeout' => 15,
        ));

        if (is_wp_error($response)) {
            return new WP_Error(
                'token_request_failed',
                // translators: %s is the underlying HTTP error message.
                sprintf(__('Vertex AI: Token request failed - %s', 'quikvox-ai'), $response->get_error_message()),
                array('details' => $response->get_error_message())
            );
        }

        $code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);

        if (200 !== $code || empty($body['access_token'])) {
            $error_desc = isset($body['error_description']) ? $body['error_description'] : 'Unknown error';
            return new WP_Error(
                'token_error',
                // translators: 1: HTTP status code. 2: error description.
                sprintf(__('Vertex AI: Token acquisition failed (%1$d) - %2$s', 'quikvox-ai'), $code, $error_desc),
                array(
                    'details' => isset($body['error']) ? $body['error'] . ': ' . $error_desc : $error_desc,
                    'http_code' => $code,
                )
            );
        }

        $this->access_token = $body['access_token'];
        $this->token_expiry = $now + (isset($body['expires_in']) ? (int)$body['expires_in'] : 3600);

        return $this->access_token;
    }

    /**
     * プロジェクトIDを取得する。
     */
    private function get_project_id()
    {
        return isset($this->service_account['project_id']) ? $this->service_account['project_id'] : '';
    }

    /**
     * Vertex AI で利用可能なボイスを取得する (静的リスト)。
     * Google AI Studio版と同じ30ボイスだが、Vertex AI固有の特性情報付き。
     *
     * @return array ボイス情報の配列。
     */
    public function get_voices()
    {
        return array(
            // Male voices (16)
                array('voice_id' => 'Puck', 'gender' => 'Male', 'characteristics' => 'Upbeat'),
                array('voice_id' => 'Charon', 'gender' => 'Male', 'characteristics' => 'Informative'),
                array('voice_id' => 'Fenrir', 'gender' => 'Male', 'characteristics' => 'Excitable'),
                array('voice_id' => 'Orus', 'gender' => 'Male', 'characteristics' => 'Firm'),
                array('voice_id' => 'Enceladus', 'gender' => 'Male', 'characteristics' => 'Breathy'),
                array('voice_id' => 'Iapetus', 'gender' => 'Male', 'characteristics' => 'Clear'),
                array('voice_id' => 'Umbriel', 'gender' => 'Male', 'characteristics' => 'Easy-going'),
                array('voice_id' => 'Algieba', 'gender' => 'Male', 'characteristics' => 'Smooth'),
                array('voice_id' => 'Algenib', 'gender' => 'Male', 'characteristics' => 'Gravelly'),
                array('voice_id' => 'Rasalgethi', 'gender' => 'Male', 'characteristics' => 'Informative'),
                array('voice_id' => 'Alnilam', 'gender' => 'Male', 'characteristics' => 'Firm'),
                array('voice_id' => 'Schedar', 'gender' => 'Male', 'characteristics' => 'Even'),
                array('voice_id' => 'Achird', 'gender' => 'Male', 'characteristics' => 'Friendly'),
                array('voice_id' => 'Zubenelgenubi', 'gender' => 'Male', 'characteristics' => 'Casual'),
                array('voice_id' => 'Sadachbia', 'gender' => 'Male', 'characteristics' => 'Lively'),
                array('voice_id' => 'Sadaltager', 'gender' => 'Male', 'characteristics' => 'Knowledgeable'),
            // Female voices (14)
                array('voice_id' => 'Zephyr', 'gender' => 'Female', 'characteristics' => 'Bright'),
                array('voice_id' => 'Kore', 'gender' => 'Female', 'characteristics' => 'Firm'),
                array('voice_id' => 'Aoede', 'gender' => 'Female', 'characteristics' => 'Breezy'),
                array('voice_id' => 'Leda', 'gender' => 'Female', 'characteristics' => 'Youthful'),
                array('voice_id' => 'Callirrhoe', 'gender' => 'Female', 'characteristics' => 'Easy-going'),
                array('voice_id' => 'Autonoe', 'gender' => 'Female', 'characteristics' => 'Bright'),
                array('voice_id' => 'Despina', 'gender' => 'Female', 'characteristics' => 'Smooth'),
                array('voice_id' => 'Erinome', 'gender' => 'Female', 'characteristics' => 'Clear'),
                array('voice_id' => 'Laomedeia', 'gender' => 'Female', 'characteristics' => 'Upbeat'),
                array('voice_id' => 'Achernar', 'gender' => 'Female', 'characteristics' => 'Soft'),
                array('voice_id' => 'Gacrux', 'gender' => 'Female', 'characteristics' => 'Mature'),
                array('voice_id' => 'Pulcherrima', 'gender' => 'Female', 'characteristics' => 'Forward'),
                array('voice_id' => 'Vindemiatrix', 'gender' => 'Female', 'characteristics' => 'Gentle'),
                array('voice_id' => 'Sulafat', 'gender' => 'Female', 'characteristics' => 'Warm'),
        );
    }

    /**
     * Vertex AI を使用してコンテンツ (テキスト) を生成する。
     *
     * @param string $model モデル名 (例: 'gemini-2.5-pro')。
     * @param string $prompt プロンプトテキスト。
     * @return string|WP_Error 生成されたテキストまたは WP_Error。
     */
    public function generate_content($model, $prompt)
    {
        $token = $this->get_access_token();
        if (is_wp_error($token)) {
            return $token;
        }

        $project_id = $this->get_project_id();
        if (empty($project_id)) {
            return new WP_Error('missing_project', __('Vertex AI: Project ID was not found.', 'quikvox-ai'));
        }

        $api_location = $this->location;
        $host_prefix = ($api_location === 'global') ? '' : $api_location . '-';

        // Vertex AI 推論 (Generate Content) エンドポイント
        $url = sprintf(
            'https://%saiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent',
            $host_prefix,
            $project_id,
            $api_location,
            $model
        );

        $body = array(
            'contents' => array(
                    array(
                    'role' => 'user',
                    'parts' => array(
                            array('text' => $prompt)
                    )
                )
            )
        );

        $response = wp_remote_post($url, array(
            'body' => json_encode($body),
            'headers' => array(
                'Content-Type' => 'application/json',
                'Authorization' => 'Bearer ' . $token,
            ),
            'timeout' => 120,
        ));

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

        $code = wp_remote_retrieve_response_code($response);
        $resp_body = wp_remote_retrieve_body($response);

        if (200 !== $code) {
            // V-07: 内部URL・プロジェクトID等をログに記録し、クライアントには汎用メッセージのみ返す
            if (defined('WP_DEBUG') && WP_DEBUG) {
                error_log('Vertex AI Error (' . $code . ') URL: ' . $url . ' Model: ' . $model . ' Body: ' . substr($resp_body, 0, 500)); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
            }
            // translators: 1: HTTP status code. 2: error response body.
            return new WP_Error('api_error', sprintf(__('Vertex AI text generation error (%1$d): %2$s', 'quikvox-ai'), $code, $resp_body));
        }

        $data = json_decode($resp_body, true);

        if (isset($data['candidates'][0]['content']['parts'][0]['text'])) {
            return $data['candidates'][0]['content']['parts'][0]['text'];
        }

        return new WP_Error('api_error', __('Vertex AI: No content was generated.', 'quikvox-ai'));
    }

    /**
     * Check whether the selected Vertex model is available in the configured location.
     *
     * @param string $kind Model family ("text" or "tts").
     * @param string $model Model name.
     * @return string|WP_Error
     */
    public function check_model_availability($kind, $model)
    {
        $normalized_kind = sanitize_key((string) $kind);

        if ($normalized_kind === 'tts') {
            return $this->generate_speech(__('Availability check.', 'quikvox-ai'), 'Charon', $model);
        }

        return $this->generate_content($model, __('Reply with a single word: OK.', 'quikvox-ai'));
    }

    /**
     * Vertex AI generateContent API を使用して音声を生成する (PCM -> WAV)。
     * 長いテキストをチャンク分割して処理する。
     *
     * @param string $text 話させるテキスト。
     * @param string $voice_config 音声名 (例: 'Charon')。
     * @param string $model TTSモデルID (例: 'gemini-2.5-flash-tts')。
     * @param string $voice_direction Voice direction instructions for the generated speech.
     * @return string|WP_Error WAVバイナリデータまたは WP_Error。
     */
    public function generate_speech($text, $voice_config = 'Charon', $model = 'gemini-2.5-flash-tts', $voice_direction = '')
    {
        $token = $this->get_access_token();
        if (is_wp_error($token)) {
            return $token;
        }

        $project_id = $this->get_project_id();
        if (empty($project_id)) {
            return new WP_Error('missing_project', __('Vertex AI: Project ID was not found.', 'quikvox-ai'));
        }

        // テキストをチャンクに分割 (Google AI Studio の Gemini TTS と同様に文字数制限)
        $chunks = $this->split_text_into_chunks($text, 1500, $voice_direction);

        if (empty($chunks)) {
            return new WP_Error('empty_text', __('Vertex AI TTS: The text is empty.', 'quikvox-ai'));
        }

        $api_location = !empty($this->tts_location) ? $this->tts_location : $this->location;
        $host_prefix = ($api_location === 'global') ? '' : $api_location . '-';

        // Vertex AI 推論 (Generate Content) エンドポイント
        $url = sprintf(
            'https://%saiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent',
            $host_prefix,
            $project_id,
            $api_location,
            $model
        );

        $combined_pcm_data = '';

        foreach ($chunks as $i => $chunk) {
            $prompt_text = $this->build_single_speaker_prompt($chunk, $voice_direction);
            $payload = array(
                'contents' => array(
                        array(
                        'role' => 'user',
                        'parts' => array(
                                array('text' => $prompt_text)
                        )
                    )
                ),
                'generationConfig' => array(
                    'responseModalities' => array('AUDIO'),
                    'speechConfig' => array(
                        'voiceConfig' => array(
                            'prebuiltVoiceConfig' => array(
                                'voiceName' => $voice_config
                            )
                        )
                    )
                )
            );

            $response = wp_remote_post($url, array(
                'body' => json_encode($payload),
                'headers' => array(
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . $token,
                ),
                'timeout' => 120,
            ));

            if (is_wp_error($response)) {
                // translators: 1: chunk number. 2: total chunk count. 3: HTTP error message.
                return new WP_Error('api_error', sprintf(__('Vertex AI TTS: Failed to send chunk %1$d/%2$d: %3$s', 'quikvox-ai'), $i + 1, count($chunks), $response->get_error_message()));
            }

            $code = wp_remote_retrieve_response_code($response);
            $resp_body = wp_remote_retrieve_body($response);

            if (200 !== $code) {
                // translators: 1: chunk number. 2: HTTP status code. 3: response body.
                return new WP_Error('api_error', sprintf(__('Vertex AI TTS error (chunk %1$d, %2$d): %3$s', 'quikvox-ai'), $i + 1, $code, $resp_body));
            }

            $data = json_decode($resp_body, true);
            $chunk_pcm = '';

            if (isset($data['candidates'][0]['content']['parts']) && is_array($data['candidates'][0]['content']['parts'])) {
                foreach ($data['candidates'][0]['content']['parts'] as $part) {
                    $inline = isset($part['inlineData']) ? $part['inlineData'] : (isset($part['inline_data']) ? $part['inline_data'] : null);
                    if ($inline && isset($inline['data'])) {
                        $chunk_pcm .= base64_decode($inline['data']);
                    }
                }
            }

            if (empty($chunk_pcm)) {
                // translators: 1: chunk number. 2: response body excerpt.
                return new WP_Error('api_error', sprintf(__('Vertex AI TTS: Audio data was not found for chunk %1$d. Response: %2$s', 'quikvox-ai'), $i + 1, substr($resp_body, 0, 500)));
            }

            $combined_pcm_data .= $chunk_pcm;
        }

        if (empty($combined_pcm_data)) {
            return new WP_Error('api_error', __('Vertex AI TTS: No audio data was generated.', 'quikvox-ai'));
        }

        return $this->add_wav_header($combined_pcm_data);
    }

    /**
     * Build the single-speaker prompt text for Vertex AI Gemini TTS.
     *
     * @param string $text The script text for a single voice.
     * @param string $voice_direction Voice direction instructions.
     * @return string
     */
    private function build_single_speaker_prompt($text, $voice_direction = '')
    {
        $normalized_text = trim((string) $text);
        $normalized_voice_direction = trim((string) $voice_direction);

        if ($normalized_voice_direction === '') {
            return $normalized_text;
        }

        return "Voice Direction:\n" . $normalized_voice_direction . "\n\nTTS the following script in a single voice:\n" . $normalized_text;
    }

    /**
     * Generate multi-speaker speech using Vertex AI Gemini TTS.
     *
     * @param array<int, array<string, string>> $turns Parsed dialogue turns.
     * @param array<string, string> $speaker_map Voice mapping keyed by speaker name.
     * @param string $model TTS model ID.
     * @return string|WP_Error WAV binary data or error.
     */
    public function generate_multi_speaker_speech($turns, $speaker_map, $model = 'gemini-2.5-flash-tts', $voice_direction = '')
    {
        $token = $this->get_access_token();
        if (is_wp_error($token)) {
            return $token;
        }

        $project_id = $this->get_project_id();
        if (empty($project_id)) {
            return new WP_Error('missing_project', __('Vertex AI: Project ID was not found.', 'quikvox-ai'));
        }

        $chunks = $this->split_turns_into_chunks($turns, $speaker_map, $voice_direction, 3200);
        if (empty($chunks)) {
            return new WP_Error('empty_text', __('Vertex AI TTS: The conversation text for Multi Speaker is empty.', 'quikvox-ai'));
        }

        $speaker_voice_configs = $this->build_speaker_voice_configs($speaker_map);
        if (count($speaker_voice_configs) !== 2) {
            return new WP_Error('invalid_speaker_voice_configs', __('Vertex AI multi-speaker TTS requires exactly two speaker voice configurations.', 'quikvox-ai'));
        }

        $api_location = !empty($this->tts_location) ? $this->tts_location : $this->location;
        $host_prefix = ($api_location === 'global') ? '' : $api_location . '-';

        $url = sprintf(
            'https://%saiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:generateContent',
            $host_prefix,
            $project_id,
            $api_location,
            $model
        );

        $combined_pcm_data = '';

        foreach ($chunks as $chunk_index => $chunk_turns) {
            $prompt_text = $this->build_multi_speaker_prompt($chunk_turns, $speaker_map, $voice_direction);

            $payload = array(
                'contents' => array(
                    array(
                        'role' => 'user',
                        'parts' => array(
                            array('text' => $prompt_text)
                        )
                    )
                ),
                'generationConfig' => array(
                    'responseModalities' => array('AUDIO'),
                    'speechConfig' => array(
                        'multiSpeakerVoiceConfig' => array(
                            'speakerVoiceConfigs' => $speaker_voice_configs,
                        )
                    )
                )
            );

            $response = wp_remote_post($url, array(
                'body' => wp_json_encode($payload),
                'headers' => array(
                    'Content-Type' => 'application/json',
                    'Authorization' => 'Bearer ' . $token,
                ),
                'timeout' => 120,
            ));

            if (is_wp_error($response)) {
                // translators: 1: chunk number. 2: total chunk count. 3: HTTP error message.
                return new WP_Error('api_error', sprintf(__('Vertex AI multi-speaker TTS: Failed to send chunk %1$d/%2$d: %3$s', 'quikvox-ai'), $chunk_index + 1, count($chunks), $response->get_error_message()));
            }

            $code = wp_remote_retrieve_response_code($response);
            $resp_body = wp_remote_retrieve_body($response);

            if (200 !== $code) {
                // translators: 1: chunk number. 2: HTTP status code. 3: response body.
                return new WP_Error('api_error', sprintf(__('Vertex AI multi-speaker TTS error (chunk %1$d, %2$d): %3$s', 'quikvox-ai'), $chunk_index + 1, $code, $resp_body));
            }

            $data = json_decode($resp_body, true);
            $chunk_pcm = '';

            if (isset($data['candidates'][0]['content']['parts']) && is_array($data['candidates'][0]['content']['parts'])) {
                foreach ($data['candidates'][0]['content']['parts'] as $part) {
                    $inline = isset($part['inlineData']) ? $part['inlineData'] : (isset($part['inline_data']) ? $part['inline_data'] : null);
                    if ($inline && isset($inline['data'])) {
                        $chunk_pcm .= base64_decode($inline['data']);
                    }
                }
            }

            if (empty($chunk_pcm)) {
                return new WP_Error('api_error', __('Vertex AI TTS: Multi-speaker audio data was not found.', 'quikvox-ai'));
            }

            $combined_pcm_data .= $chunk_pcm;
        }

        if (empty($combined_pcm_data)) {
            return new WP_Error('api_error', __('Vertex AI TTS: No multi-speaker audio data was generated.', 'quikvox-ai'));
        }

        return $this->add_wav_header($combined_pcm_data);
    }

    /**
     * Generate a single multi-speaker chunk while applying the voice direction prompt.
     *
     * @param array<int, array<string, string>> $turns Parsed dialogue turns for one chunk.
     * @param array<string, string>             $speaker_map Voice mapping keyed by speaker.
     * @param string                            $model The TTS model.
     * @param string                            $voice_direction The voice direction text.
     * @return string|WP_Error
     */
    public function generate_multi_speaker_chunk_speech($turns, $speaker_map, $model = 'gemini-2.5-flash-tts', $voice_direction = '')
    {
        return $this->generate_multi_speaker_speech($turns, $speaker_map, $model, $voice_direction);
    }

    /**
     * テキストを指定文字数以下のチャンクに分割する。
     * 文の区切り (。！？!\n) で分割を試みる。
     */
    private function split_text_into_chunks($text, $max_chars = 1500, $voice_direction = '')
    {
        $text = trim($text);
        if (empty($text)) {
            return array();
        }

        $prompt_overhead = mb_strlen($this->build_single_speaker_prompt('', $voice_direction), 'UTF-8');
        $content_budget = max(200, $max_chars - $prompt_overhead);

        if (mb_strlen($this->build_single_speaker_prompt($text, $voice_direction), 'UTF-8') <= $max_chars) {
            return array($text);
        }

        // 文区切りで分割
        $sentences = preg_split('/(。|！|？|!|\?|\n)+/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);

        $chunks = array();
        $current_chunk = '';

        foreach ($sentences as $piece) {
            if (mb_strlen($this->build_single_speaker_prompt($current_chunk . $piece, $voice_direction), 'UTF-8') <= $max_chars) {
                $current_chunk .= $piece;
            }
            else {
                if (!empty(trim($current_chunk))) {
                    $chunks[] = trim($current_chunk);
                }

                if (mb_strlen($this->build_single_speaker_prompt($piece, $voice_direction), 'UTF-8') > $max_chars) {
                    // この文が単独で制限を超える場合、強制分割
                    $sub_chunks = $this->force_split_by_chars($piece, $content_budget);
                    foreach ($sub_chunks as $sc) {
                        $chunks[] = $sc;
                    }
                    $current_chunk = '';
                }
                else {
                    $current_chunk = $piece;
                }
            }
        }

        if (!empty(trim($current_chunk))) {
            $chunks[] = trim($current_chunk);
        }

        return $chunks;
    }

    /**
     * Split parsed dialogue turns into prompt-sized chunks while preserving turns.
     *
     * @param array<int, array<string, string>> $turns Parsed dialogue turns.
     * @param int $max_bytes Maximum bytes per chunk.
     * @return array<int, array<int, array<string, string>>>
     */
    private function split_turns_into_chunks($turns, $speaker_map, $voice_direction = '', $max_total_bytes = 3200)
    {
        $chunks = array();
        $current_chunk = array();
        $voice_direction = is_string($voice_direction) ? trim($voice_direction) : '';

        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;
            }

            $single_turn_payload_bytes = $this->estimate_multi_speaker_prompt_bytes(array($turn), $speaker_map, $voice_direction);

            if ($single_turn_payload_bytes > $max_total_bytes) {
                if (!empty($current_chunk)) {
                    $chunks[] = $current_chunk;
                    $current_chunk = array();
                }

                $split_turns = $this->split_turn_text_by_bytes($speaker_name, $turn_text, $speaker_map, max(600, $max_total_bytes - $this->estimate_multi_speaker_prompt_bytes(array(), $speaker_map, $voice_direction) - 32));
                foreach ($split_turns as $split_turn) {
                    $chunks[] = array($split_turn);
                }
                continue;
            }

            $candidate_chunk = $current_chunk;
            $candidate_chunk[] = $turn;
            if ($this->estimate_multi_speaker_prompt_bytes($candidate_chunk, $speaker_map, $voice_direction) > $max_total_bytes) {
                $chunks[] = $current_chunk;
                $current_chunk = array($turn);
                continue;
            }

            $current_chunk[] = $turn;
        }

        if (!empty($current_chunk)) {
            $chunks[] = $current_chunk;
        }

        return $chunks;
    }

    /**
     * Split a single oversized turn into multiple turn fragments.
     *
     * @param string $speaker_name Speaker name.
     * @param string $turn_text Turn text.
     * @param int $max_bytes Maximum bytes per fragment.
     * @return array<int, array<string, string>>
     */
    private function split_turn_text_by_bytes($speaker_name, $turn_text, $speaker_map, $max_bytes)
    {
        $prefix = $this->format_multi_speaker_prompt_label($speaker_name, $speaker_map) . ': ';
        $available_bytes = max(200, $max_bytes - strlen($prefix));
        $segments = array();
        $offset = 0;
        $text_bytes = strlen($turn_text);

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

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

        return $segments;
    }

    /**
     * Build the multi-speaker prompt string expected by Gemini TTS.
     *
     * @param array<int, array<string, string>> $turns Parsed dialogue turns.
     * @return string
     */
    private function build_multi_speaker_prompt($turns, $speaker_map, $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;
            }

            $display_label = $this->format_multi_speaker_prompt_label($speaker_name, $speaker_map);
            if (!in_array($display_label, $speaker_names, true)) {
                $speaker_names[] = $display_label;
            }
            $lines[] = $display_label . ': ' . $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";
        }

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

    /**
     * Estimate the UTF-8 byte size for a multi-speaker prompt.
     *
     * @param array<int, array<string, string>> $turns Parsed dialogue turns.
     * @param string                            $voice_direction The voice direction text.
     * @return int
     */
    private function estimate_multi_speaker_prompt_bytes($turns, $speaker_map, $voice_direction = '')
    {
        return strlen($this->build_multi_speaker_prompt($turns, $speaker_map, $voice_direction));
    }

    /**
     * Build the label used in multi-speaker TTS prompts.
     *
     * @param string $speaker_name Speaker name.
     * @param array<string, string> $speaker_map Voice mapping keyed by speaker name.
     * @return string
     */
    private function format_multi_speaker_prompt_label($speaker_name, $speaker_map)
    {
        $normalized_speaker_name = $this->sanitize_speaker_name($speaker_name);
        if ($normalized_speaker_name === '') {
            return '';
        }

        $mapped_voice_name = $this->get_speaker_map_value($speaker_map, $speaker_name);
        if ($mapped_voice_name === '') {
            return $normalized_speaker_name;
        }

        return $normalized_speaker_name . ' (' . $mapped_voice_name . ')';
    }

    /**
     * Resolve a speaker map value with case-insensitive key matching.
     *
     * @param array<string, string> $speaker_map Voice mapping keyed by speaker name.
     * @param string $speaker_name Speaker name.
     * @return string
     */
    private function get_speaker_map_value($speaker_map, $speaker_name)
    {
        $speaker_map = is_array($speaker_map) ? $speaker_map : array();
        $normalized_speaker_name = $this->sanitize_speaker_name($speaker_name);
        if ($normalized_speaker_name === '') {
            return '';
        }

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

        return '';
    }

    /**
     * Sanitize a speaker name for comparison while preserving readable labels.
     *
     * @param string $speaker_name 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);
    }

    /**
     * Build the speaker voice config payload for Vertex AI Gemini TTS.
     *
     * @param array<string, string> $speaker_map Voice mapping keyed by speaker name.
     * @return array<int, array<string, mixed>>
     */
    private function build_speaker_voice_configs($speaker_map)
    {
        $configs = array();
        $ordered_speaker_names = array('Speaker1', 'Speaker2');
        $remaining_speaker_names = array();

        foreach (array_keys($speaker_map) as $speaker_name) {
            if (!in_array($speaker_name, $ordered_speaker_names, true)) {
                $remaining_speaker_names[] = $speaker_name;
            }
        }

        foreach (array_merge($ordered_speaker_names, $remaining_speaker_names) as $speaker_name) {
            if (!isset($speaker_map[$speaker_name])) {
                continue;
            }

            $voice_name = $speaker_map[$speaker_name];
            $configs[] = array(
                'speaker' => $speaker_name,
                'voiceConfig' => array(
                    'prebuiltVoiceConfig' => array(
                        'voiceName' => $voice_name,
                    )
                )
            );
        }

        return $configs;
    }

    /**
     * テキストを文字数で強制分割する (マルチバイト安全)。
     */
    private function force_split_by_chars($text, $max_chars)
    {
        $chunks = array();
        $len = mb_strlen($text, 'UTF-8');

        for ($i = 0; $i < $len; $i += $max_chars) {
            $chunks[] = mb_substr($text, $i, $max_chars, 'UTF-8');
        }

        return $chunks;
    }

    /**
     * Raw PCM データに WAV ヘッダーを追加する。
     */
    private function add_wav_header($pcm_data, $sample_rate = 24000, $channels = 1, $bits_per_sample = 16)
    {
        $data_len = strlen($pcm_data);
        $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 . $pcm_data;
    }
}
