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

/**
 * 7社のAI APIを統一的に扱うクライアント
 *
 * Claude (Anthropic) / OpenAI / Gemini / Mistral / Groq / DeepSeek / OpenRouter
 * Mistral/Groq/DeepSeek/OpenRouter は OpenAI互換API
 */
class AIAP_AI_Client {

    const OPTION_KEY = 'aiap_settings';

    /**
     * プロバイダ×モデルごとの機能サポートマトリクス
     *
     * OpenAI系は近年 max_tokens → max_completion_tokens への移行や、
     * Reasoning モデル(o1/o3/o4/gpt-5 系)固有の制約があるため、
     * モデルIDから系統を判定してAPIリクエストを動的に組み立てる必要がある。
     *
     * @param string $provider プロバイダキー
     * @param string $model    モデルID (空なら既定値)
     * @return array {
     *     @type string $token_param              トークン上限パラメータ名 ('max_tokens' | 'max_completion_tokens' | '')
     *     @type bool   $supports_system_message  system ロールを受け付けるか
     *     @type string $family                   API系統 (openai-chat / openai-reasoning / claude / gemini / openai-compatible)
     * }
     */
    public static function get_model_capabilities( $provider, $model = '' ) {
        if ( $model === '' ) {
            $settings = self::get_settings();
            $model    = $settings[ $provider . '_model' ] ?? '';
        }

        if ( $provider === 'claude' ) {
            return array(
                'token_param'             => 'max_tokens',
                'supports_system_message' => true,
                'family'                  => 'claude',
            );
        }

        if ( $provider === 'gemini' ) {
            // Geminiは独自フォーマット(system_instruction / contents)
            return array(
                'token_param'             => '',
                'supports_system_message' => true,
                'family'                  => 'gemini',
            );
        }

        if ( $provider === 'openai' ) {
            if ( self::is_openai_reasoning_model( $model ) ) {
                return array(
                    'token_param'             => 'max_completion_tokens',
                    'supports_system_message' => true, // 現行のo1/o3/gpt-5はsystemロール可 (developerロール相当として扱われる)
                    'family'                  => 'openai-reasoning',
                );
            }
            return array(
                'token_param'             => 'max_tokens',
                'supports_system_message' => true,
                'family'                  => 'openai-chat',
            );
        }

        // OpenAI互換(Mistral/Groq/DeepSeek/OpenRouter)は各社とも現状 max_tokens を受け付ける
        return array(
            'token_param'             => 'max_tokens',
            'supports_system_message' => true,
            'family'                  => 'openai-compatible',
        );
    }

    /**
     * OpenAIの Reasoning 系モデル(max_completion_tokens 必須)かを判定
     *
     * 対象: gpt-5 / gpt-5-mini / gpt-5-nano / gpt-5.2 系 / o1 / o1-mini / o3 / o3-mini / o4-mini など
     * 非対象: gpt-4o / gpt-4.1 / gpt-3.5-turbo / dall-e 系 / gpt-image 系
     */
    public static function is_openai_reasoning_model( $model ) {
        if ( ! is_string( $model ) || $model === '' ) {
            return false;
        }
        // "gpt-5" で始まる (ただし "gpt-5-image" などの画像系は除外)
        if ( preg_match( '/^gpt-5/', $model ) && strpos( $model, 'image' ) === false ) {
            return true;
        }
        // "o1", "o3", "o4" など単一英字+数字で始まるReasoning系
        if ( preg_match( '/^o[1-9](\b|[-\d])/', $model ) ) {
            return true;
        }
        return false;
    }

    /**
     * OpenAI互換プロバイダのエンドポイント定義
     */
    private static function get_openai_compatible_endpoints() {
        return array(
            'mistral' => array(
                'url'   => 'https://api.mistral.ai/v1/chat/completions',
                'key'   => 'mistral_api_key',
                'model' => 'mistral_model',
            ),
            'groq' => array(
                'url'   => 'https://api.groq.com/openai/v1/chat/completions',
                'key'   => 'groq_api_key',
                'model' => 'groq_model',
            ),
            'deepseek' => array(
                'url'   => 'https://api.deepseek.com/chat/completions',
                'key'   => 'deepseek_api_key',
                'model' => 'deepseek_model',
            ),
            'openrouter' => array(
                'url'   => 'https://openrouter.ai/api/v1/chat/completions',
                'key'   => 'openrouter_api_key',
                'model' => 'openrouter_model',
            ),
        );
    }

    /**
     * APIキーフィールド一覧
     */
    private static function get_key_fields() {
        return array( 'claude_api_key', 'openai_api_key', 'gemini_api_key', 'mistral_api_key', 'groq_api_key', 'deepseek_api_key', 'openrouter_api_key' );
    }

    public static function get_settings() {
        $defaults = array(
            // プロバイダ選択
            'provider'            => 'claude',
            // Claude
            'claude_api_key'      => '',
            'claude_model'        => 'claude-sonnet-4-5-20250929',
            // OpenAI
            'openai_api_key'      => '',
            'openai_model'        => 'gpt-4.1-mini',
            // Gemini
            'gemini_api_key'      => '',
            'gemini_model'        => 'gemini-2.5-flash',
            // Mistral
            'mistral_api_key'     => '',
            'mistral_model'       => 'mistral-small-latest',
            // Groq
            'groq_api_key'        => '',
            'groq_model'          => 'llama-3.3-70b-versatile',
            // DeepSeek
            'deepseek_api_key'    => '',
            'deepseek_model'      => 'deepseek-chat',
            // OpenRouter
            'openrouter_api_key'  => '',
            'openrouter_model'    => 'anthropic/claude-sonnet-4',
            // システムプロンプト
            'system_prompt'       => '',
            'content_language'    => 'ja',
            // 0 = AI 任せ (文字数指示を一切送らない)
            // 数値を入れると、その値に応じた長さの指示が送られる:
            //   - 800 以下: 「approximately N、do NOT exceed」 (短文として上限重視)
            //   - 2000 以下: 「around N」 (目安)
            //   - 2000 超:  「at least N」 (最低字数として強く要求)
            'content_length'      => 0,
            // 投稿設定
            'post_status'         => 'draft',
            'post_category'       => 0,
            'post_author'         => 1,
            'enabled'             => 1,
            // 類似回避
            'dedup_enabled'       => 0,
            'dedup_step1'         => 1,
            'dedup_step2'         => 1,
            'dedup_max_retry'     => 3,
            'dedup_sensitivity'   => 'medium',
            // 自動実行(cron)関連
            // 取りこぼし復元(過去の未実行予定を後から拾うか)
            'cron_recover_missed'   => 1,
            // 復元する最大件数(1〜5)
            'cron_recover_max'      => 3,
            // SEO
            'seo_enabled'         => 0,
            'seo_meta_desc'       => 1,
            'seo_heading'         => 1,
            'seo_focus_kw'        => 1,
            'seo_faq'             => 0,
            'seo_toc'             => 0,
            'seo_template'        => 0,
            'seo_img_alt'         => 1,
            'seo_img_webp'        => 0,
            'seo_internal_link'   => 1,
            'seo_auto_tag'        => 1,
            'seo_related'         => 0,
            'seo_schema'          => 1,
            'seo_ogp'             => 0,
            'seo_canonical'       => 0,
            'seo_plugin_link'     => 0,
            'seo_reading_time'    => 0,
            'seo_sitemap_ping'    => 0,
            'seo_llms_txt'        => 0,
        );
        $saved = get_option( self::OPTION_KEY, array() );
        $settings = wp_parse_args( $saved, $defaults );

        // APIキーを復号化して返す
        return AIAP_Crypto::decrypt_keys( $settings, self::get_key_fields() );
    }

    public static function update_settings( $settings ) {
        // Bug fix (audit r9): AUTH_KEY 欠落 (または openssl 拡張なし) 環境では get_settings →
        // decrypt_keys が API キー fields を空文字に置換して返す。その状態で他設定を変更して
        // update_settings を呼ぶと、暗号化済みの既存値が空で上書きされてユーザーの API キーが
        // 永久に失われる。Crypto::is_available() が false の場合は、API キー fields だけ DB の
        // 既存値を読み戻して温存する (=「他の設定だけ更新する」状態)。
        if ( ! AIAP_Crypto::is_available() ) {
            $existing = get_option( self::OPTION_KEY, array() );
            if ( is_array( $existing ) ) {
                foreach ( self::get_key_fields() as $f ) {
                    if ( ! empty( $existing[ $f ] ) ) {
                        // DB の既存値 (暗号文または旧平文) をそのまま温存
                        $settings[ $f ] = $existing[ $f ];
                    }
                }
            }
        }
        // APIキーを暗号化して保存
        // autoload=false: 暗号化済みAPIキーや system_prompt 等を含む大きめのオプション。
        // 管理画面ロード時のみ参照されるため、フロント全リクエストで自動ロードする必要はない。
        $settings = AIAP_Crypto::encrypt_keys( $settings, self::get_key_fields() );
        update_option( self::OPTION_KEY, $settings, false );
    }

    /**
     * 全プロバイダ一覧
     */
    public static function get_providers() {
        return array( 'claude', 'openai', 'gemini', 'mistral', 'groq', 'deepseek', 'openrouter' );
    }

    /**
     * 各プロバイダのモデル選択肢
     */
    const MODEL_CACHE_PREFIX = 'aiap_models_';
    const MODEL_CACHE_TTL = DAY_IN_SECONDS;

    /**
     * 指定プロバイダのモデル一覧を返す。キャッシュ(transient)があればそれを優先。
     */
    public static function get_models_for( $provider ) {
        $cached = get_transient( self::MODEL_CACHE_PREFIX . $provider );
        if ( is_array( $cached ) && ! empty( $cached ) ) {
            return $cached;
        }
        $defaults = self::get_model_options();
        return $defaults[ $provider ] ?? array();
    }

    /**
     * 動的取得したモデル一覧をキャッシュに保存
     */
    public static function cache_models( $provider, array $models ) {
        set_transient( self::MODEL_CACHE_PREFIX . $provider, $models, self::MODEL_CACHE_TTL );
    }

    /**
     * 各プロバイダのAPIからモデル一覧を取得
     * @return array|WP_Error array( model_id => label, ... )
     */
    public static function fetch_models_from_api( $provider ) {
        $settings = self::get_settings();
        switch ( $provider ) {
            case 'claude':
                return self::fetch_claude_models( $settings['claude_api_key'] ?? '' );
            case 'openai':
                return self::fetch_openai_models( $settings['openai_api_key'] ?? '', 'text' );
            case 'gemini':
                return self::fetch_gemini_models( $settings['gemini_api_key'] ?? '', 'text' );
            case 'mistral':
                return self::fetch_openai_compatible_models( $settings['mistral_api_key'] ?? '', 'https://api.mistral.ai/v1/models' );
            case 'groq':
                return self::fetch_openai_compatible_models( $settings['groq_api_key'] ?? '', 'https://api.groq.com/openai/v1/models' );
            case 'deepseek':
                return self::fetch_openai_compatible_models( $settings['deepseek_api_key'] ?? '', 'https://api.deepseek.com/v1/models' );
            case 'openrouter':
                return self::fetch_openai_compatible_models( $settings['openrouter_api_key'] ?? '', 'https://openrouter.ai/api/v1/models' );
            // 画像プロバイダ: 画像AIプロバイダ設定のAPIキーを使う
            case 'dalle3':
                if ( class_exists( 'AIAP_Image_Client' ) ) {
                    $image_settings = AIAP_Image_Client::get_settings();
                    return self::fetch_openai_models( $image_settings['image_openai_api_key'] ?? '', 'image' );
                }
                return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
            case 'imagen':
                if ( class_exists( 'AIAP_Image_Client' ) ) {
                    $image_settings = AIAP_Image_Client::get_settings();
                    return self::fetch_gemini_models( $image_settings['image_gemini_api_key'] ?? '', 'image' );
                }
                return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
        }
        /* translators: %s: provider key */
        return new WP_Error( 'unsupported_provider', sprintf( __( 'Dynamic fetch not supported for: %s', 'ai-auto-poster' ), $provider ) );
    }

    private static function fetch_claude_models( $api_key ) {
        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
        }
        $response = wp_remote_get( 'https://api.anthropic.com/v1/models', array(
            'timeout' => 20,
            'headers' => array(
                'x-api-key'         => $api_key,
                'anthropic-version' => '2023-06-01',
            ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            /* translators: %d: HTTP status code returned by the Claude API */
            return new WP_Error( 'api_error', sprintf( __( 'Claude API: HTTP %d', 'ai-auto-poster' ), wp_remote_retrieve_response_code( $response ) ) );
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $data['data'] ) || ! is_array( $data['data'] ) ) {
            return new WP_Error( 'empty_response', __( 'No models returned', 'ai-auto-poster' ) );
        }
        $result = array();
        foreach ( $data['data'] as $m ) {
            $id = $m['id'] ?? '';
            if ( $id === '' ) continue;
            $name = $m['display_name'] ?? $id;
            $result[ $id ] = $id . ( $name !== $id ? ' — ' . $name : '' );
        }
        return $result;
    }

    private static function fetch_openai_models( $api_key, $kind = 'text' ) {
        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
        }
        $response = wp_remote_get( 'https://api.openai.com/v1/models', array(
            'timeout' => 20,
            'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            /* translators: %d: HTTP status code returned by the OpenAI API */
            return new WP_Error( 'api_error', sprintf( __( 'OpenAI API: HTTP %d', 'ai-auto-poster' ), wp_remote_retrieve_response_code( $response ) ) );
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $data['data'] ) || ! is_array( $data['data'] ) ) {
            return new WP_Error( 'empty_response', __( 'No models returned', 'ai-auto-poster' ) );
        }
        $result = array();
        foreach ( $data['data'] as $m ) {
            $id = $m['id'] ?? '';
            if ( $id === '' ) continue;
            if ( $kind === 'text' ) {
                // gpt-*, o1/o3/o4系(ハイフン有無問わず), chatgpt-* のうち
                // embedding/audio/moderation/whisper/tts 系を除外
                if ( ! preg_match( '/^(gpt-|chatgpt-|o[1-9](?:$|[-\d]))/', $id ) ) continue;
                if ( preg_match( '/(embedding|audio|whisper|tts|moderation|realtime|transcribe|search)/i', $id ) ) continue;
            } else {
                // image: dall-e-*, gpt-image-*
                if ( ! preg_match( '/^(dall-e-|gpt-image-)/', $id ) ) continue;
            }
            $result[ $id ] = $id;
        }
        ksort( $result );
        return $result;
    }

    private static function fetch_gemini_models( $api_key, $kind = 'text' ) {
        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
        }
        $url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( $api_key );
        $response = wp_remote_get( $url, array( 'timeout' => 20 ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            /* translators: %d: HTTP status code returned by the Gemini API */
            return new WP_Error( 'api_error', sprintf( __( 'Gemini API: HTTP %d', 'ai-auto-poster' ), wp_remote_retrieve_response_code( $response ) ) );
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( empty( $data['models'] ) || ! is_array( $data['models'] ) ) {
            return new WP_Error( 'empty_response', __( 'No models returned', 'ai-auto-poster' ) );
        }
        $result = array();
        foreach ( $data['models'] as $m ) {
            $name = $m['name'] ?? '';
            if ( strpos( $name, 'models/' ) === 0 ) {
                $id = substr( $name, 7 );
            } else {
                $id = $name;
            }
            if ( $id === '' ) continue;
            $methods = $m['supportedGenerationMethods'] ?? array();
            if ( $kind === 'text' ) {
                if ( strpos( $id, 'imagen' ) === 0 ) continue;
                if ( strpos( $id, 'embedding' ) !== false ) continue;
                if ( strpos( $id, 'aqa' ) !== false ) continue;
                if ( ! in_array( 'generateContent', $methods, true ) ) continue;
            } else {
                if ( strpos( $id, 'imagen' ) !== 0 ) continue;
            }
            $result[ $id ] = $id;
        }
        ksort( $result );
        return $result;
    }

    private static function fetch_openai_compatible_models( $api_key, $url ) {
        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'API key is required', 'ai-auto-poster' ) );
        }
        $response = wp_remote_get( $url, array(
            'timeout' => 20,
            'headers' => array( 'Authorization' => 'Bearer ' . $api_key ),
        ) );
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            /* translators: %d: HTTP status code returned by the upstream API */
            return new WP_Error( 'api_error', sprintf( __( 'HTTP %d', 'ai-auto-poster' ), wp_remote_retrieve_response_code( $response ) ) );
        }
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        $items = $data['data'] ?? ( $data['models'] ?? array() );
        if ( empty( $items ) || ! is_array( $items ) ) {
            return new WP_Error( 'empty_response', __( 'No models returned', 'ai-auto-poster' ) );
        }
        $result = array();
        foreach ( $items as $m ) {
            $id = $m['id'] ?? ( $m['name'] ?? '' );
            if ( $id === '' ) continue;
            // embedding/audio などを除外
            if ( preg_match( '/(embedding|audio|whisper|tts|moderation|rerank|search|vision)/i', $id ) ) continue;
            $result[ $id ] = $id;
        }
        ksort( $result );
        return $result;
    }

    public static function get_model_options() {
        return array(
            'claude' => array(
                'claude-sonnet-4-5-20250929' => 'claude-sonnet-4-5 — Recommended',
                'claude-opus-4-5-20250929'   => 'claude-opus-4-5 — High quality',
                'claude-haiku-4-5-20251001'  => 'claude-haiku-4-5 — Fast & affordable',
            ),
            'openai' => array(
                'gpt-4.1-mini' => 'gpt-4.1-mini — Recommended',
                'gpt-4.1'      => 'gpt-4.1 — High quality',
                'gpt-4.1-nano' => 'gpt-4.1-nano — Fastest & most affordable',
                'gpt-4o'       => 'gpt-4o',
                'gpt-4o-mini'  => 'gpt-4o-mini',
                'o3-mini'      => 'o3-mini — Reasoning',
            ),
            'gemini' => array(
                'gemini-2.5-flash' => 'gemini-2.5-flash — Recommended',
                'gemini-2.5-pro'   => 'gemini-2.5-pro — High quality',
                'gemini-2.0-flash' => 'gemini-2.0-flash — Affordable',
            ),
            'mistral' => array(
                'mistral-small-latest'  => 'mistral-small-latest — Recommended',
                'mistral-medium-latest' => 'mistral-medium-latest',
                'mistral-large-latest'  => 'mistral-large-latest — High quality',
                'codestral-latest'      => 'codestral-latest — Code',
            ),
            'groq' => array(
                'llama-3.3-70b-versatile' => 'llama-3.3-70b-versatile — Recommended',
                'llama-3.1-8b-instant'    => 'llama-3.1-8b-instant — Fastest',
                'gemma2-9b-it'            => 'gemma2-9b-it',
                'mixtral-8x7b-32768'      => 'mixtral-8x7b-32768',
            ),
            'deepseek' => array(
                'deepseek-chat'     => 'deepseek-chat — Recommended',
                'deepseek-reasoner' => 'deepseek-reasoner — Reasoning',
            ),
            'openrouter' => array(
                'anthropic/claude-sonnet-4'   => 'anthropic/claude-sonnet-4',
                'openai/gpt-4.1-mini'         => 'openai/gpt-4.1-mini',
                'google/gemini-2.5-flash'     => 'google/gemini-2.5-flash',
                'meta-llama/llama-3.3-70b'    => 'meta-llama/llama-3.3-70b',
                'mistralai/mistral-large'     => 'mistralai/mistral-large',
                'deepseek/deepseek-chat-v3'   => 'deepseek/deepseek-chat-v3',
            ),
            // 画像プロバイダのデフォルトモデルリスト
            'dalle3' => array(
                'dall-e-3'      => 'dall-e-3 — Recommended',
                'dall-e-2'      => 'dall-e-2 — Legacy',
                'gpt-image-1'   => 'gpt-image-1',
            ),
            'imagen' => array(
                'imagen-4.0-generate-001'      => 'imagen-4.0-generate-001 — Recommended',
                'imagen-4.0-fast-generate-001' => 'imagen-4.0-fast-generate-001 — Fast',
                'imagen-3.0-generate-002'      => 'imagen-3.0-generate-002',
                'imagen-3.0-generate-001'      => 'imagen-3.0-generate-001',
                'imagen-3.0-fast-generate-001' => 'imagen-3.0-fast-generate-001',
            ),
        );
    }

    /**
     * 言語設定の選択肢
     */
    public static function get_language_options() {
        // セレクトボックス用の言語コード→ネイティブ表記
        // (AIへの指示には言語コードを使う。ネイティブ表記はUI表示のためだけ)
        // 世界の主要言語 約50。話者数の多い順をベースに、地域ごとに代表的な言語を網羅。
        return array(
            // アジア東部
            'ja'    => '日本語',
            'zh'    => '中文 (简体)',
            'zh-TW' => '繁體中文',
            'ko'    => '한국어',

            // 国際語
            'en'    => 'English',

            // ヨーロッパ主要
            'es'    => 'Español',
            'fr'    => 'Français',
            'de'    => 'Deutsch',
            'it'    => 'Italiano',
            'pt'    => 'Português',
            'pt-BR' => 'Português (Brasil)',
            'ru'    => 'Русский',
            'nl'    => 'Nederlands',
            'pl'    => 'Polski',
            'sv'    => 'Svenska',
            'no'    => 'Norsk',
            'da'    => 'Dansk',
            'fi'    => 'Suomi',
            'cs'    => 'Čeština',
            'sk'    => 'Slovenčina',
            'hu'    => 'Magyar',
            'ro'    => 'Română',
            'el'    => 'Ελληνικά',
            'bg'    => 'Български',
            'uk'    => 'Українська',

            // 中東・北アフリカ
            'ar'    => 'العربية',
            'he'    => 'עברית',
            'fa'    => 'فارسی',
            'tr'    => 'Türkçe',

            // 南アジア
            'hi'    => 'हिन्दी',
            'bn'    => 'বাংলা',
            'ur'    => 'اردو',
            'ta'    => 'தமிழ்',
            'te'    => 'తెలుగు',
            'mr'    => 'मराठी',
            'gu'    => 'ગુજરાતી',
            'pa'    => 'ਪੰਜਾਬੀ',

            // 東南アジア
            'id'    => 'Bahasa Indonesia',
            'ms'    => 'Bahasa Melayu',
            'th'    => 'ไทย',
            'vi'    => 'Tiếng Việt',
            'tl'    => 'Tagalog',
            'my'    => 'မြန်မာ',
            'km'    => 'ខ្មែរ',
            'lo'    => 'ລາວ',

            // アフリカ・その他
            'sw'    => 'Kiswahili',
            'af'    => 'Afrikaans',
            'am'    => 'አማርኛ',

            // 自由入力
            'other' => 'Other',
        );
    }

    const GENERATE_MAX_RETRY = 3;

    /**
     * generate() を最大N回までリトライするラッパー
     *
     * @param string $user_prompt
     * @param callable|null $on_retry function(int $attempt, WP_Error $err) — 各リトライ時にログ等を出すコールバック
     * @param array $overrides 一時的な上書き設定 ('content_language' 等)
     * @param bool  $is_manual  手動実行かどうか (true なら現在日時 override を参照しない)
     * @return array|WP_Error 最後の成功結果、または最終失敗のWP_Error
     */
    public static function generate_with_retry( $user_prompt, $on_retry = null, $overrides = array(), $is_manual = false ) {
        // 注: RSS 注入処理は generate() 側で行う（Quick Post の do_single_generate_and_check が
        // generate() を直接呼ぶ経路でも注入が効くようにするため）。

        $result = null;
        // GENERATE_MAX_RETRY は「リトライ最大回数」。よって試行は最大 (N+1) 回(初回 + リトライ N 回)。
        for ( $attempt = 1; $attempt <= self::GENERATE_MAX_RETRY + 1; $attempt++ ) {
            // Bug fix (audit r9 self-audit): 画像 call_with_retry と同じ理由で、各
            // attempt の頭で実行時間制限をリセットする。AI 生成は wp_remote_post
            // timeout=120 秒 を最大 4 回 + sleep(1+2+3) かかるため、共用ホスト等の
            // max_execution_time=30 秒環境ではリトライ完走前に殺される。
            if ( function_exists( 'set_time_limit' ) ) {
                @set_time_limit( 0 );
            }

            $result = self::generate( $user_prompt, $overrides, $is_manual );
            if ( ! is_wp_error( $result ) ) {
                return $result;
            }

            // エラー種別を見てリトライ可否を判定。
            // 401/403/400 等の入力/権限エラーは再試行しても解消しないので即返却する。
            $err_data = $result->get_error_data();
            $http_code = is_array( $err_data ) && isset( $err_data['http_code'] ) ? (int) $err_data['http_code'] : 0;
            $retry_after = is_array( $err_data ) && isset( $err_data['retry_after'] ) ? (int) $err_data['retry_after'] : 0;

            // build_api_error_wp_error が retryable=false を立てている場合は即返却。
            // (429 のうち課金/クォータ枯渇など、コードだけでは判別できない永続エラー対応)
            if ( is_array( $err_data ) && array_key_exists( 'retryable', $err_data ) && ! $err_data['retryable'] ) {
                return $result;
            }

            // http_code=0 の場合 (HTTP 通信失敗 / parse_error 等) はリトライ可能扱い。
            // http_code が記録されていてリトライ不可コードなら即返却。
            // (retryable キーが入っていない経路の後方互換チェック)
            if ( $http_code !== 0 && ! self::is_retryable_http_code( $http_code ) ) {
                return $result;
            }

            // 最後の試行(リトライ N 回目を実行した直後)は次の試行がないので待機しない。
            // ループ上限 = GENERATE_MAX_RETRY + 1 (試行最大回数) なので、それ未満の時のみリトライ前処理を行う。
            if ( $attempt <= self::GENERATE_MAX_RETRY ) {
                if ( is_callable( $on_retry ) ) {
                    call_user_func( $on_retry, $attempt, $result );
                }
                // 待機秒数は AI_Client::compute_retry_sleep_seconds() に集約 (#35)。
                sleep( self::compute_retry_sleep_seconds( $attempt, $retry_after ) );
            }
        }
        return $result;
    }

    /**
     * プロンプトを送信して、タイトルと本文を生成
     *
     * @param string $user_prompt
     * @param array  $overrides  一時的な上書き設定 ('content_language' 等)
     * @param bool   $is_manual  手動実行かどうか (true なら現在日時 override を参照しない)
     * @return array|WP_Error array( 'title' => ..., 'content' => ..., 'slug' => ... )
     */
    public static function generate( $user_prompt, $overrides = array(), $is_manual = false ) {
        // RSS 注入: overrides に rss_articles があれば Composer 経由でプロンプトを合成。
        // Quick Post (do_single_generate_and_check) と自動 (generate_with_retry) の
        // 両方の経路でここを通るため、注入は generate() に集約する。
        // $user_prompt はローカル変数で書き換えるので呼び出し元に影響しない。
        if ( is_array( $overrides )
             && ! empty( $overrides['rss_articles'] )
             && is_array( $overrides['rss_articles'] )
             && class_exists( 'AIAP_RSS_Composer' ) ) {
            $rss_mode    = isset( $overrides['rss_mode'] ) ? (string) $overrides['rss_mode'] : 'pick';
            $user_prompt = AIAP_RSS_Composer::inject( $user_prompt, $overrides['rss_articles'], $rss_mode );
        }

        $settings = self::get_settings();
        if ( ! empty( $overrides ) && is_array( $overrides ) ) {
            $settings = array_merge( $settings, $overrides );
        }
        $provider = $settings['provider'];
        // Bug fix (audit r4): SEO is a Pro feature; on a downgraded site a stale
        // seo_enabled=1 should not cause SEO directives + extra JSON fields (and thus
        // larger token usage) to be sent to the AI. Mirror the runtime Pro gate that
        // AIAP_SEO::apply() now enforces.
        $seo      = ! empty( $settings['seo_enabled'] )
            && ( ! class_exists( 'AIAP_License' ) || AIAP_License::is_pro() );

        // ユーザーがタスクで指定した言語コード(ja/en/zh/ko/es/other)
        $lang_code = isset( $settings['content_language'] ) ? $settings['content_language'] : 'ja';

        // 言語指示(英語固定)。AIは英語の指示でも、対象言語で出力できる
        $lang_hard_rule = sprintf(
            "**LANGUAGE REQUIREMENT (HIGHEST PRIORITY, MUST OBEY)**: "
            . "Write the article (title and content) in language code '%s'. "
            . "Even if the user prompt is written in another language, that is only reference material, not the output language. "
            . "Both 'title' and 'content' MUST be in language '%s' (slug may be English). "
            . "Ignoring this language directive is strictly forbidden.",
            $lang_code, $lang_code
        );

        // === [先頭ブロック] 英語固定 ===
        // 仕様順序: 1. 現在日時 2. 言語指示 3. 文字数指示(指定時のみ)
        $system_parts = array();
        $system_parts[] = self::build_current_datetime_context( $is_manual );
        $system_parts[] = $lang_hard_rule;

        // 文字数指示はユーザが明示的に指定した場合のみ追加(0/未指定なら送らない)
        $length = isset( $settings['content_length'] ) ? (int) $settings['content_length'] : 0;
        if ( $length > 0 ) {
            // ユーザーが指定した数値を素直に伝える。「at least」「MUST」など強い表現を使わず、
            // AI が指定値を上回る/下回る大きなズレを起こしにくい中立な表現を採用する。
            $system_parts[] = sprintf(
                "**ARTICLE LENGTH**: Write the article body to approximately %d characters. "
                . "Try to stay close to this target; significant deviation in either direction is not desired.",
                $length
            );
        }

        // SEOシステム指示(英語ベース・言語ニュートラル)
        if ( $seo ) {
            $seo_instructions = array();
            if ( ! empty( $settings['seo_heading'] ) ) {
                $seo_instructions[] = 'Use H2 and H3 for headings to form a logical hierarchy. Do NOT use H1.';
            }
            if ( ! empty( $settings['seo_template'] ) ) {
                $seo_instructions[] = 'Follow this article structure: introduction -> 3-5 H2 sections -> conclusion.';
            }
            if ( ! empty( $settings['seo_faq'] ) ) {
                $seo_instructions[] = 'Add an FAQ section at the end with 3-5 Q&A pairs. Each question should be an H3 heading followed by its answer.';
            }
            if ( ! empty( $settings['seo_toc'] ) ) {
                $seo_instructions[] = 'Insert a Table of Contents (HTML list) at the top of the article body, with anchor links to each heading (use id attributes).';
            }
            if ( ! empty( $seo_instructions ) ) {
                $system_parts[] = "[SEO INSTRUCTIONS]\n" . implode( "\n", $seo_instructions );
            }
        }

        // 直近10件のAI生成投稿タイトルを取得し、類似タイトル回避指示を追加。
        // 既存の dedup (Pro機能) は生成後の類似度チェックで再生成を促すが、
        // ここは生成前にプロンプト側で「これらと似た記事は書かないで」と
        // 抑制することで、再生成コスト/トークン浪費を未然に減らす。
        $recent_titles = self::get_recent_generated_titles( 10 );
        if ( ! empty( $recent_titles ) ) {
            $titles_list = "- " . implode( "\n- ", $recent_titles );
            $system_parts[] = "[AVOID DUPLICATE TOPICS]\n"
                . "The following are titles of the 10 most recently generated posts on this site. "
                . "Do NOT write an article whose title or main topic is the same as, or very similar to, any of these. "
                . "Pick a clearly different angle, subject, or sub-topic.\n"
                . $titles_list;
        }

        if ( ! empty( $settings['system_prompt'] ) ) {
            $system_parts[] = $settings['system_prompt'];
        }
        $system_text = implode( "\n", $system_parts );

        // JSON出力フィールドを構築(英語ベース)
        // 設計意図: AI は前のフィールドから順に書く心理になり、content を最後に置くことで
        //          「最後に残った余裕で content を長く書く」誘導になる。逆に content を
        //          最初に置くと、AI がメタフィールドのトークン分も見越して content を
        //          短く切り上げる傾向がある。
        // メタフィールド (短いもの) を先に
        $json_fields = array(
            '"title": "article title"',
            '"slug": "english-slug-for-url"',
        );
        $json_notes = array(
            'slug must be an English URL slug. Use only lowercase letters, digits, and hyphens. 3-6 words is ideal.',
        );

        if ( $seo ) {
            if ( ! empty( $settings['seo_meta_desc'] ) ) {
                $json_fields[] = '"meta_description": "meta description, MUST be between 120 and 155 characters"';
                $json_notes[] = 'meta_description is required. It MUST contain at least 120 characters and no more than 155 characters. Summarize the article naturally; do not pad with filler words. If the article is short, expand by elaborating on the key takeaway in the same language as the body.';
            }
            if ( ! empty( $settings['seo_focus_kw'] ) ) {
                $json_fields[] = '"focus_keyword": "1-3 word focus keyword"';
                $json_notes[] = 'focus_keyword is the main keyword for the article. Include it naturally in the title, headings, and body.';
            }
            if ( ! empty( $settings['seo_auto_tag'] ) ) {
                $json_fields[] = '"tags": ["tag1", "tag2", "tag3"]';
                $json_notes[] = 'tags must be 3-5 WordPress tags relevant to the article.';
            }
            // Bug fix (audit r6): seo_faq 有効時は AI 本文に FAQ セクションを書かせると同時に、
            // 同じ Q&A を構造化 JSON でも返させる。これにより AIAP_SEO::apply() で FAQPage
            // JSON-LD を生成し、Google の FAQ 構造化データ要件を満たせる
            // (本文の H3 を正規表現で抽出する方式は誤検出が多いため、AI 出力を一次ソースとする)。
            if ( ! empty( $settings['seo_faq'] ) ) {
                $json_fields[] = '"faq": [{"question": "...", "answer": "..."}]';
                $json_notes[] = 'faq must mirror the FAQ section that you put in the body. 3-5 entries, each with a question and a concise answer (in the same language as the body). Do not include HTML in question or answer; plain text only.';
            }
            // image_alt はアイキャッチが実際に付く場合のみ意味を持つ。
            // effective が OFF (per-post で「付けない」を選んだケース) では出力させない。
            $image_settings_for_alt = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
            $effective_for_alt = isset( $overrides['image_enabled_effective'] )
                ? (bool) $overrides['image_enabled_effective']
                : ! empty( $image_settings_for_alt['image_enabled'] );
            if ( ! empty( $settings['seo_img_alt'] ) && $effective_for_alt ) {
                $json_fields[] = '"image_alt": "alt text for the featured image"';
            }
        }

        // AI画像生成が有効ならimage_promptを必須フィールドに追加
        // image_enabled_effective が overrides で渡っていればタスク/手動側の per-post 設定を優先する。
        $image_settings_for_prompt = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
        $image_source_active = isset( $image_settings_for_prompt['image_source'] ) ? $image_settings_for_prompt['image_source'] : '';
        $effective_image_enabled = isset( $overrides['image_enabled_effective'] )
            ? (bool) $overrides['image_enabled_effective']
            : ! empty( $image_settings_for_prompt['image_enabled'] );
        $is_ai_image_active = $effective_image_enabled
                            && in_array( $image_source_active, array( 'dalle3', 'imagen', 'stability', 'flux' ), true );
        if ( $is_ai_image_active ) {
            $json_fields[] = '"image_prompt": "detailed English image-generation prompt"';
            $json_notes[] = 'image_prompt is the English prompt for AI image generation. Describe the scene of the article body in detail (place, season, time of day, concrete objects). If the body mentions stationery, people, animals, or plants, include them in the English prompt. Do NOT include any text in the image.';
        }

        // RSS pick モード時: AI に「どの記事を選んだか」を JSON で返させるためのフィールドを追加。
        // user message 側 (rss-composer.php) でも指示しているが、system 側の "Return ONLY the
        // following JSON format" 指示が強いため、ここに含めないと AI が省略してしまう。
        // 結果として selected_rss_index=0 (= 未指定でデフォルト) となり、log_rss_pick_index_invalid
        // 警告が毎回出ていたバグの修正。
        $is_rss_pick = ! empty( $overrides['rss_articles'] )
                    && is_array( $overrides['rss_articles'] )
                    && ( ! isset( $overrides['rss_mode'] ) || (string) $overrides['rss_mode'] === 'pick' );
        if ( $is_rss_pick ) {
            $json_fields[] = '"selected_rss_index": 1';
            $json_notes[]  = 'selected_rss_index is the 1-based index (an integer between 1 and N inclusive) of the news article you chose from the candidate list above.';
        }

        // content フィールドは最後に追加 (JSON フィールド順序の心理誘導: メタデータを先に、
        // 本体を最後に書く流れにすると AI が content に時間とトークンを十分に割きやすい)。
        // ただし「long」「detailed」のような長文化を促す誘導は付けない。文字数指示は
        // 上の ARTICLE LENGTH ブロックでのみ行い、ここでは中立な記述に留める。
        $json_fields[] = '"content": "HTML body of the article"';
        $json_notes[] = 'content is the main article body in HTML.';

        $instruction = "\n\nReturn ONLY the following JSON format. No preamble or postscript:\n"
                     . '{' . implode( ', ', $json_fields ) . '}'
                     . "\n\n" . implode( "\n", $json_notes );

        // === [末尾ブロック] 英語固定・念押し ===
        // 仕様: 先頭と同じ順序で再掲(日時 → 言語 → 文字数)
        $instruction .= "\n\n" . self::build_current_datetime_context( $is_manual );
        $instruction .= sprintf(
            "\n\n**FINAL LANGUAGE REMINDER**: 'title' and 'content' MUST be in language code '%s'. "
            . "Even if the user instructions above are written in another language, that does NOT change the output language. "
            . "Outputting in any language other than '%s' is forbidden.",
            $lang_code, $lang_code
        );
        if ( $length > 0 ) {
            $instruction .= sprintf(
                "\n\n**ARTICLE LENGTH REMINDER**: Aim for approximately %d characters in the article body.",
                $length
            );
        }

        // Bug fix (audit r9 self-audit, #46): user メッセージ末尾の system_prompt 再掲を削除。
        // system_prompt は既に system メッセージに含めて送られている (L716-718)。
        // user メッセージの末尾に「must obey」付きで再掲すると、LLM の recency bias で
        // ユーザーが入力した主題 (user_prompt = 例: 「ゴルフ」) が末尾の system_prompt 再掲に
        // 押し負け、AI が主題を無視して system_prompt を優先してしまう問題があった。

        $full_prompt = $user_prompt . $instruction;

        switch ( $provider ) {
            case 'claude':
                $raw = self::call_claude( $settings, $system_text, $full_prompt );
                break;
            case 'openai':
                $raw = self::call_openai( $settings, $system_text, $full_prompt );
                break;
            case 'gemini':
                $raw = self::call_gemini( $settings, $system_text, $full_prompt );
                break;
            case 'mistral':
            case 'groq':
            case 'deepseek':
            case 'openrouter':
                $raw = self::call_openai_compatible( $provider, $settings, $system_text, $full_prompt );
                break;
            default:
                /* translators: %s: provider key */
                return new WP_Error( 'invalid_provider', sprintf( __( 'Unknown provider: %s', 'ai-auto-poster' ), $provider ) );
        }

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

        if ( trim( (string) $raw ) === '' ) {
            return new WP_Error( 'empty_ai_response', __( 'AI returned empty response before parsing.', 'ai-auto-poster' ) );
        }

        // タスク/手動側の per-post 設定 (image_enabled_effective) を parse_response にも引き継ぐ。
        // 本文中の「Image Prompt:」風記述を抽出するか否かの判定に使う。
        $effective_for_parse = isset( $overrides['image_enabled_effective'] )
            ? (bool) $overrides['image_enabled_effective']
            : null;

        return self::parse_response( $raw, $effective_for_parse );
    }

    /**
     * 直近 N 件の AI 生成投稿タイトルを取得
     *
     * プロンプトに「これらと似た記事は書かないで」と指示するために使う。
     * 対象は _aiap_generated=1 (= AI Auto Poster が生成した) 投稿のみ。
     * 手動編集された他の記事はノイズになるため除外。
     *
     * @param int $limit 取得件数 (デフォルト 10)
     * @return array<int,string> 投稿日降順のタイトル配列 (空文字は除外)
     */
    private static function get_recent_generated_titles( $limit = 10 ) {
        $limit = max( 1, (int) $limit );

        $post_ids = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'posts_per_page' => $limit,
            'orderby'        => 'date',
            'order'          => 'DESC',
            'meta_query'     => array(
                array(
                    'key'   => '_aiap_generated',
                    'value' => '1',
                ),
            ),
            'fields'         => 'ids',
            'no_found_rows'  => true,
            'suppress_filters' => true,
        ) );

        $titles = array();
        foreach ( $post_ids as $pid ) {
            $title = trim( (string) get_the_title( $pid ) );
            if ( $title !== '' ) {
                $titles[] = $title;
            }
        }
        return $titles;
    }

    /**
     * Claude API呼び出し
     */
    private static function call_claude( $settings, $system, $prompt ) {
        if ( empty( $settings['claude_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Claude API key is not set', 'ai-auto-poster' ) );
        }

        $response = wp_remote_post( 'https://api.anthropic.com/v1/messages', array(
            'timeout' => 120,
            'headers' => array(
                'Content-Type'      => 'application/json',
                'x-api-key'         => $settings['claude_api_key'],
                'anthropic-version' => '2023-06-01',
            ),
            'body' => wp_json_encode( array(
                'model'      => $settings['claude_model'],
                // 出力上限。日本語の長文記事は 4096 では途中打ち切り頻発のため 16000 に拡大。
                // max_tokens は「最大値」であり実際の課金は AI が出力した分のみ。
                'max_tokens' => 16000,
                'system'     => $system,
                'messages'   => array(
                    array( 'role' => 'user',      'content' => $prompt ),
                    // assistant プレフィル: 応答を必ず '{' から始めさせ、思考過程・前置き文・
                    // コードブロックの混入を物理的に防ぐ。戻り値側で '{' を補完する。
                    array( 'role' => 'assistant', 'content' => '{' ),
                ),
            ) ),
        ) );

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

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

        if ( $code !== 200 ) {
            return self::build_api_error_wp_error( 'claude_error', 'Claude', $response );
        }

        $content = isset( $data['content'][0]['text'] ) ? (string) $data['content'][0]['text'] : '';
        if ( trim( $content ) === '' ) {
            $stop_reason = isset( $data['stop_reason'] ) ? (string) $data['stop_reason'] : 'unknown';
            /* translators: %s: stop_reason returned by the Claude API */
            return new WP_Error( 'empty_ai_response', sprintf( __( 'Claude returned empty response (stop_reason: %s)', 'ai-auto-poster' ), $stop_reason ) );
        }
        // assistant プレフィルで送った '{' は応答に含まれないため、先頭に補完する。
        return '{' . $content;
    }

    /**
     * OpenAI API呼び出し
     *
     * NOTE: トークン上限を指定する場合は、必ず self::get_model_capabilities('openai', $model)['token_param']
     *       を参照すること。Reasoning系モデル(gpt-5/o1/o3/o4)は max_tokens ではなく
     *       max_completion_tokens を要求する。直接 'max_tokens' をハードコードすると400エラーになる。
     */
    private static function call_openai( $settings, $system, $prompt ) {
        if ( empty( $settings['openai_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'OpenAI API key is not set', 'ai-auto-poster' ) );
        }

        // モデルごとに正しいトークン上限パラメータ名を使う
        // (Reasoningモデル=gpt-5/o1/o3/o4は max_completion_tokens 必須)
        $caps = self::get_model_capabilities( 'openai', $settings['openai_model'] );
        $body = array(
            'model'    => $settings['openai_model'],
            'messages' => array(
                array( 'role' => 'system', 'content' => $system ),
                array( 'role' => 'user',   'content' => $prompt ),
            ),
            // JSON 出力強制(system/user メッセージに 'JSON' の語が含まれる必要があるが、
            //  generate() の instruction に 'Return ONLY ... JSON format' があるので満たす)
            'response_format' => array( 'type' => 'json_object' ),
        );
        if ( ! empty( $caps['token_param'] ) ) {
            // 出力上限。日本語の長文記事は 4096 では途中打ち切り頻発のため 16000 に拡大。
            // Reasoning 系 (o1/o3 等) は思考トークン分も消費するため同じく 16000。
            // max_tokens は「最大値」であり実際の課金は AI が出力した分のみ。
            $body[ $caps['token_param'] ] = 16000;
        }

        $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', array(
            'timeout' => 120,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $settings['openai_api_key'],
            ),
            'body' => wp_json_encode( $body ),
        ) );

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

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

        if ( $code !== 200 ) {
            return self::build_api_error_wp_error( 'openai_error', 'OpenAI', $response );
        }

        $content = isset( $data['choices'][0]['message']['content'] ) ? (string) $data['choices'][0]['message']['content'] : '';
        if ( trim( $content ) === '' ) {
            $finish = isset( $data['choices'][0]['finish_reason'] ) ? (string) $data['choices'][0]['finish_reason'] : 'unknown';
            /* translators: %s: finish_reason returned by the OpenAI API */
            return new WP_Error( 'empty_ai_response', sprintf( __( 'OpenAI returned empty response (finish_reason: %s)', 'ai-auto-poster' ), $finish ) );
        }
        return $content;
    }

    /**
     * Gemini API呼び出し
     */
    private static function call_gemini( $settings, $system, $prompt ) {
        if ( empty( $settings['gemini_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Gemini API key is not set', 'ai-auto-poster' ) );
        }

        $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
             . rawurlencode( $settings['gemini_model'] )
             . ':generateContent?key=' . rawurlencode( $settings['gemini_api_key'] );

        $response = wp_remote_post( $url, array(
            'timeout' => 120,
            'headers' => array( 'Content-Type' => 'application/json' ),
            'body'    => wp_json_encode( array(
                'system_instruction' => array(
                    'parts' => array( array( 'text' => $system ) ),
                ),
                'contents' => array(
                    array(
                        'role'  => 'user',
                        'parts' => array( array( 'text' => $prompt ) ),
                    ),
                ),
                // 出力上限。Gemini はデフォルト 8192 程度のため、日本語長文記事だと
                // 途中打ち切りが発生する。明示的に 16000 を指定する。
                // 加えて responseMimeType を application/json にして JSON 出力を強制する。
                'generationConfig' => array(
                    'maxOutputTokens' => 16000,
                    'responseMimeType' => 'application/json',
                ),
            ) ),
        ) );

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

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

        if ( $code !== 200 ) {
            return self::build_api_error_wp_error( 'gemini_error', 'Gemini', $response );
        }

        $content = isset( $data['candidates'][0]['content']['parts'][0]['text'] )
            ? (string) $data['candidates'][0]['content']['parts'][0]['text']
            : '';
        if ( trim( $content ) === '' ) {
            $finish = isset( $data['candidates'][0]['finishReason'] ) ? (string) $data['candidates'][0]['finishReason'] : 'unknown';
            /* translators: %s: finishReason returned by the Gemini API */
            return new WP_Error( 'empty_ai_response', sprintf( __( 'Gemini returned empty response (finish_reason: %s)', 'ai-auto-poster' ), $finish ) );
        }
        return $content;
    }

    /**
     * OpenAI互換API呼び出し（Mistral / Groq / DeepSeek / OpenRouter）
     */
    /**
     * OpenAI互換API呼び出し (Mistral / Groq / DeepSeek / OpenRouter)
     *
     * NOTE: トークン上限を指定する場合は self::get_model_capabilities($provider, $model)['token_param']
     *       を参照すること。OpenRouter は OpenAI のモデル(gpt-5 等)もプロキシ配信するため、
     *       選択モデル次第で max_completion_tokens 側になる可能性がある。
     */
    private static function call_openai_compatible( $provider, $settings, $system, $prompt ) {
        $endpoints = self::get_openai_compatible_endpoints();

        if ( ! isset( $endpoints[ $provider ] ) ) {
            /* translators: %s: provider key */
            return new WP_Error( 'unknown_provider', sprintf( __( 'Unknown provider: %s', 'ai-auto-poster' ), $provider ) );
        }

        $ep    = $endpoints[ $provider ];
        $key   = isset( $settings[ $ep['key'] ] ) ? $settings[ $ep['key'] ] : '';
        $model = isset( $settings[ $ep['model'] ] ) ? $settings[ $ep['model'] ] : '';

        if ( empty( $key ) ) {
            return new WP_Error( 'no_api_key', ucfirst( $provider ) . ' API key is not set' );
        }

        $headers = array(
            'Content-Type'  => 'application/json',
            'Authorization' => 'Bearer ' . $key,
        );

        // OpenRouter requires additional headers
        if ( $provider === 'openrouter' ) {
            $headers['HTTP-Referer'] = home_url();
            $headers['X-Title']     = 'AI Auto Poster';
        }

        // モデルごとに正しいトークン上限パラメータ名を使う
        // (OpenRouter は OpenAI のgpt-5系もプロキシ配信するため可変)
        $caps = self::get_model_capabilities( $provider, $model );
        $body = array(
            'model'    => $model,
            'messages' => array(
                array( 'role' => 'system', 'content' => $system ),
                array( 'role' => 'user',   'content' => $prompt ),
            ),
            // JSON 出力強制(主要互換APIで対応。OpenRouter の一部モデルは非対応で
            // 400 になる場合があるが、その場合はモデルを変更すること)
            'response_format' => array( 'type' => 'json_object' ),
        );
        if ( ! empty( $caps['token_param'] ) ) {
            // 出力上限。日本語の長文記事は 4096 では途中打ち切り頻発のため 16000 に拡大。
            // 一部のプロバイダ(Mistral 等)は実際の上限が低い場合もあるが、その場合は
            // API 側で内部的にクランプされるため害なし。
            $body[ $caps['token_param'] ] = 16000;
        }

        $response = wp_remote_post( $ep['url'], array(
            'timeout' => 120,
            'headers' => $headers,
            'body'    => wp_json_encode( $body ),
        ) );

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

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

        if ( $code !== 200 ) {
            // Bug fix (audit r9): 旧実装は生 WP_Error を返していたため retryable データが
            // 付かず、generate_with_retry が 400/401/403 等の永続エラーでも「不明 → 再試行可」と
            // 判定して無駄なリトライを繰り返していた。他プロバイダ (Claude/OpenAI/Gemini) と同じく
            // build_api_error_wp_error 経由で classify_api_error を通すことで、リトライ可否・
            // ヒント文 (i18n)・raw_message を正しく付与する。
            return self::build_api_error_wp_error( $provider . '_error', ucfirst( $provider ), $response );
        }

        $content = isset( $data['choices'][0]['message']['content'] ) ? (string) $data['choices'][0]['message']['content'] : '';
        if ( trim( $content ) === '' ) {
            $finish = isset( $data['choices'][0]['finish_reason'] ) ? (string) $data['choices'][0]['finish_reason'] : 'unknown';
            return new WP_Error( 'empty_ai_response', ucfirst( $provider ) . ' returned empty response (finish_reason: ' . $finish . ')' );
        }
        return $content;
    }

    /**
     * content の末尾や先頭にある「Image Prompt: ...」行を抽出し、contentから取り除く
     * (AIがJSONキーではなくcontent内に画像プロンプトを書いた場合のサルベージ)
     *
     * @param string $content
     * @return array array( 'prompt' => string, 'content' => string )
     */
    private static function extract_image_prompt_from_content( $content ) {
        if ( $content === '' ) {
            return array( 'prompt' => '', 'content' => $content );
        }
        // 「Image Prompt:」「画像プロンプト:」「画像プロンプト:」などを許容
        // 厳格化: AI が本文末尾に独立段落として書いた場合のみマッチ。
        // 本文の途中で「画像プロンプト機能を使えば〜」のような言及を誤検出して
        // 後続を全削除しないよう、以下の追加条件を課す:
        //   1. 直前は段落終端 (</p>, </div>, </section>, 改行、または冒頭)
        //   2. ラベル後の本文には HTML タグを含まない (生のプロンプト英文を期待)
        //   3. ラベル後の本文長は 300 文字以下 (本文の長文を誤検出しない)
        //
        // Bug fix (audit r4): 旧来の `[::]` 文字クラスは ASCII コロンが 2 つ重複しているだけで、
        //   全角コロン「:」(U+FF1A) を取り損ねていた。日本語スタイルの「画像プロンプト:」を
        //   正しく拾えるよう、Unicode コードポイントで明示する。
        $re = '/(?:^|<\/p>\s*|<\/div>\s*|<\/section>\s*|\n\s*\n)'
            . '[ \t]*(?:Image\s*Prompt|Image\s*prompt|画像プロンプト)\s*[:\x{FF1A}]\s*'
            . '([^<\n]{1,300})\s*\z/us';
        if ( preg_match( $re, $content, $m, PREG_OFFSET_CAPTURE ) ) {
            $prompt   = trim( wp_strip_all_tags( $m[1][0] ) );
            // 抽出されたプロンプトが空 or 異常に短い場合はスキップ
            if ( $prompt === '' || strlen( $prompt ) < 5 ) {
                return array( 'prompt' => '', 'content' => $content );
            }
            $offset   = $m[0][1];
            $length   = strlen( $m[0][0] );
            $new_content = substr( $content, 0, $offset ) . substr( $content, $offset + $length );
            $new_content = rtrim( $new_content );
            return array( 'prompt' => $prompt, 'content' => $new_content );
        }
        return array( 'prompt' => '', 'content' => $content );
    }

    /**
     * 現在日時のコンテキスト文字列を作る(AIのシステムプロンプトに埋め込む)
     *
     * WordPressのタイムゾーン設定(wp_timezone)に基づいて、
     * 年月日・曜日・時間帯・タイムゾーン名をAIに伝える。
     * 季節はAIが月+タイムゾーンから自己判定する前提で渡さない。
     *
     * @return string
     */
    /**
     * AIに送る現在日時情報(英語固定)
     *
     * 言語に依存しない: AIは英語の指示を理解した上で、ユーザーが指定した言語で記事を書く。
     *
     * @return string
     */
    private static function build_current_datetime_context( $is_manual = false ) {
        $tz_name  = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : date_default_timezone_get();
        $tz       = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( $tz_name );

        // 取りこぼし後追い実行時は本来の予定日時を使う
        // (Scheduler::run() が AIAP_Poster::run_single() 直前にセットする)
        // 手動実行は取りこぼし対象ではないため override は参照しない
        // (cron と手動が同時走行した場合に、自動側の override を手動側が拾わないようにするため)
        $override = null;
        if ( ! $is_manual && class_exists( 'AIAP_Scheduler' ) ) {
            $raw = get_option( AIAP_Scheduler::OPTION_OVERRIDE_POSTDATE, array() );
            if ( is_array( $raw )
                && ! empty( $raw['date_str'] )
                && ! empty( $raw['time_str'] )
                && isset( $raw['set_at'] )
                && ( time() - (int) $raw['set_at'] ) < 300
            ) {
                $override = $raw;
            }
        }

        try {
            if ( $override ) {
                $base = new DateTimeImmutable( $override['date_str'] . ' ' . $override['time_str'] . ':00', $tz );
            } else {
                $base = new DateTimeImmutable( 'now', $tz );
            }
        } catch ( Exception $e ) {
            $base = new DateTimeImmutable( 'now' );
        }

        $ymd     = $base->format( 'Y-m-d' );
        $hour    = (int) $base->format( 'G' );
        $dow_num = (int) $base->format( 'w' ); // 0=Sun, 1=Mon, ..., 6=Sat

        // 曜日(英語固定)
        $dow_short = array( 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' );
        $dow = isset( $dow_short[ $dow_num ] ) ? $dow_short[ $dow_num ] : '';

        // 時間帯12分類(英語固定)
        $period = self::hour_to_period_label_en( $hour );

        return "**CURRENT DATE & TIME (the moment this article is being written — match it)**:\n"
             . "- Date: {$ymd} ({$dow})\n"
             . "- Time of day: {$period}\n"
             . "- Timezone: {$tz_name}\n"
             . "Use this to infer the correct season and climate. Do NOT describe seasons that contradict the current month.";
    }

    /**
     * 時刻(0-23)を英語の時間帯ラベル(12分類)に変換
     */
    private static function hour_to_period_label_en( $hour ) {
        if ( $hour >= 0  && $hour <= 3  ) return 'late night';
        if ( $hour === 4 )                return 'pre-dawn';
        if ( $hour === 5 )                return 'dawn';
        if ( $hour === 6 )                return 'early morning';
        if ( $hour >= 7  && $hour <= 9  ) return 'morning';
        if ( $hour >= 10 && $hour <= 11 ) return 'late morning';
        if ( $hour >= 12 && $hour <= 13 ) return 'noon';
        if ( $hour >= 14 && $hour <= 16 ) return 'afternoon';
        if ( $hour >= 17 && $hour <= 18 ) return 'evening';
        if ( $hour === 19 )               return 'dusk';
        if ( $hour >= 20 && $hour <= 22 ) return 'night';
        return 'midnight';
    }


    /**
     * スマートクォート等を標準ASCIIに正規化（JSONパース前の前処理）
     */
    private static function normalize_quotes( $text ) {
        // 注意: 鍵括弧「」(U+300C/U+300D) は日本語の本文で頻出する正常な記号のため、
        //       絶対に置換しない。過去に置換していた時代があり「本文中の「悩み」」が
        //       JSON の文字列終端として誤認されて本文が途中で打ち切られる重大バグを起こした。
        //       現在は ASCII の " " などタイプセット系の引用符のみを正規化する。
        $map = array(
            "\xE2\x80\x9C" => '"', // “ U+201C
            "\xE2\x80\x9D" => '"', // ” U+201D
            "\xE2\x80\x9E" => '"', // „ U+201E
            "\xE2\x80\x9F" => '"', // ‟ U+201F
            "\xE3\x80\x9D" => '"', // 〝 U+301D
            "\xE3\x80\x9E" => '"', // 〞 U+301E
            "\xEF\xBC\x82" => '"', // ＂ U+FF02
            "\xE2\x80\x98" => "'", // ‘ U+2018
            "\xE2\x80\x99" => "'", // ’ U+2019
            "\xE2\x80\x9A" => "'", // ‚ U+201A
            "\xE2\x80\x9B" => "'", // ‛ U+201B
        );
        return strtr( $text, $map );
    }

    /**
     * API エラーレスポンスから短く読める1行メッセージを作る
     * - JSONの error.message が取れればそれを使用
     * - 取れない場合(HTML返却など)は HTTP ステータス + <title> or 先頭を抽出して短縮
     *
     * @param int    $code  HTTPステータスコード
     * @param string $body  レスポンスボディ(raw)
     * @param array  $data  json_decodeの結果(取れていれば)
     * @return string 整形済みエラー文字列
     */
    private static function format_api_error_message( $code, $body, $data ) {
        // 1) JSONから message が取れる場合
        // OpenAI/Anthropic/Gemini 系: error.message
        // Stability/Pixabay/Pexels 系: 直接 message / detail / errors[0].message
        if ( is_array( $data ) ) {
            $candidate = '';
            if ( isset( $data['error']['message'] ) ) {
                $candidate = (string) $data['error']['message'];
            } elseif ( isset( $data['message'] ) && is_string( $data['message'] ) ) {
                $candidate = (string) $data['message'];
            } elseif ( isset( $data['detail'] ) && is_string( $data['detail'] ) ) {
                $candidate = (string) $data['detail'];
            } elseif ( isset( $data['errors'][0]['message'] ) ) {
                $candidate = (string) $data['errors'][0]['message'];
            } elseif ( isset( $data['errors'] ) && is_array( $data['errors'] ) ) {
                // Stability のように errors が文字列配列の場合
                $first = reset( $data['errors'] );
                if ( is_string( $first ) ) {
                    $candidate = $first;
                }
            }
            if ( $candidate !== '' ) {
                return sprintf( 'HTTP %d: %s', $code, self::scrub_secrets( $candidate ) );
            }
        }
        // 2) HTML (502/503/504 Cloudflareエラーなど) → <title> を抽出
        if ( is_string( $body ) && stripos( ltrim( $body ), '<' ) === 0 ) {
            if ( preg_match( '/<title>\s*(.*?)\s*<\/title>/is', $body, $m ) ) {
                $title = trim( wp_strip_all_tags( $m[1] ) );
                if ( $title !== '' ) {
                    return sprintf( 'HTTP %d: %s', $code, self::scrub_secrets( mb_substr( $title, 0, 120 ) ) );
                }
            }
            // titleも無ければ HTMLタグ除去して先頭120字
            $text = trim( wp_strip_all_tags( $body ) );
            $text = preg_replace( '/\s+/', ' ', $text );
            if ( $text !== '' ) {
                return sprintf( 'HTTP %d: %s', $code, self::scrub_secrets( mb_substr( $text, 0, 120 ) ) );
            }
            return sprintf(
                /* translators: %d = HTTP status code */
                __( 'HTTP %d (upstream error)', 'ai-auto-poster' ),
                $code
            );
        }
        // 3) プレーンテキスト → 先頭120字
        $text = is_string( $body ) ? trim( $body ) : '';
        if ( $text !== '' ) {
            return sprintf( 'HTTP %d: %s', $code, self::scrub_secrets( mb_substr( $text, 0, 120 ) ) );
        }
        return sprintf( 'HTTP %d', $code );
    }

    /**
     * エラーメッセージ等に紛れ込んだ可能性のある API キー/トークンをマスクする
     *
     * 通常は API 側がレスポンスにキーをエコーすることはないが、誤実装プロキシや
     * デバッグメッセージ経由で混入する可能性に備える防御層。ログや UI 表示前に
     * 必ず通すこと。
     *
     * @param string $text サニタイズ対象文字列
     * @return string マスク後の文字列
     */
    public static function scrub_secrets( $text ) {
        if ( ! is_string( $text ) || $text === '' ) {
            return '';
        }
        // Bearer トークン: "Bearer sk-...xxx" → "Bearer ***"
        $text = preg_replace( '/Bearer\s+[A-Za-z0-9_\-\.]{8,}/i', 'Bearer ***', $text );
        // sk-/sk_/pk_ で始まる長めの英数字トークン (OpenAI / Anthropic / Stripe 系)
        $text = preg_replace( '/\b(sk|pk)[\-_][A-Za-z0-9_\-]{16,}/', '$1-***', $text );
        // x-api-key ヘッダ風の `key=...` `api_key=...`
        $text = preg_replace( '/(api[_-]?key\s*[:=]\s*)["\']?[A-Za-z0-9_\-]{8,}["\']?/i', '$1***', $text );
        return $text;
    }

    /**
     * HTTP エラーがリトライすべき種別か判定する。
     *
     * - 429 (Rate Limit): リトライ可能
     * - 408 / 500 / 502 / 503 / 504: 一時的サーバ側エラー、リトライ可能
     * - 401 / 403: 認証/権限エラー、リトライ無意味
     * - 400 / 404 / 422: 入力エラー、リトライ無意味
     * - その他: 安全側に倒してリトライ不可
     *
     * @param int $code HTTP ステータスコード
     * @return bool
     */
    public static function is_retryable_http_code( $code ) {
        $code = (int) $code;
        // Bug fix (audit r9 self-audit): Anthropic Claude は過負荷時に固有の
        // 529 Overloaded を返す。HTTP の標準コードではないため標準 5xx ハンドラの
        // リストに無く、これまで「リトライしない一般エラー」扱いで 1 回で諦めていた。
        // 加えて Cloudflare がオリジンと通信できない時に返す 520〜527 系も一時的な
        // エッジ起因のエラーで、AIAP が叩く API (api.anthropic.com / api.openai.com /
        // api.mistral.ai / openrouter.ai 等は全て Cloudflare 経由) で発生しうる。
        // これらも retryable に含める。
        return in_array( $code, array(
            408, 429,
            500, 502, 503, 504,
            520, 521, 522, 523, 524, 525, 526, 527, // Cloudflare edge errors
            529,                                     // Anthropic Overloaded
        ), true );
    }

    /**
     * リトライ間の待機秒数を計算する単一の真実の供給源 (single source of truth)。
     *
     * Bug fix (audit r9 self-audit): 旧実装は同じ指数バックオフ + jitter ロジックが
     *   1. AI_Client::generate_with_retry の sleep (cron 経路の AI 生成)
     *   2. Image_Client::call_with_retry の sleep (画像生成、cron + UI 両方)
     *   3. assets/admin.js の runAiLoop の setTimeout (UI 経路の AI 生成)
     * の 3 箇所に重複して書かれていた。バックオフ調整のたびに 3 箇所同期する必要があり
     * DRY 違反だった。本ヘルパーに集約し、PHP の retry 機構はここを呼び、
     * UI (admin.js) は ajax レスポンスに含まれる next_sleep_seconds の値を使う。
     *
     * 待機戦略:
     *   1. API が Retry-After ヘッダで指示してきたらそれを尊重 (上限 60 秒)
     *      - Anthropic 529 / OpenAI 429 等で値が来る
     *   2. なければ指数バックオフ: 2^attempt 秒 (1→2, 2→4, 3→8)
     *   3. 0~1 秒のランダム jitter を加算
     *      - thundering herd (複数プラグインが同タイミングで API 叩く事故) を回避
     *   4. 最低 2 秒、最大 30 秒で clamp
     *      - 1 リクエスト内で過剰な sleep を避ける一方、過負荷サーバへの追い打ちも防ぐ
     *
     * 旧線形バックオフ ($attempt 秒 = 1+2+3=6 秒) では Anthropic 529 のような
     * 数十秒の過負荷状態に追いつかず、3 回とも同じ過負荷ウィンドウに当たって全敗する
     * パターンになっていた。
     *
     * @param int $attempt     何回目のリトライか (1, 2, 3, ...)
     * @param int $retry_after API レスポンスの Retry-After ヘッダの値 (秒)。0 なら無視。
     * @return int 待機秒数
     */
    public static function compute_retry_sleep_seconds( $attempt, $retry_after = 0 ) {
        $attempt     = max( 1, (int) $attempt );
        $retry_after = max( 0, (int) $retry_after );

        if ( $retry_after > 0 ) {
            return min( 60, $retry_after );
        }

        $base_sleep = (int) pow( 2, $attempt );           // 2, 4, 8, 16, ...
        $jitter     = mt_rand( 0, 1000 ) / 1000.0;        // 0.000-1.000
        $sleep      = (int) ceil( $base_sleep + $jitter );
        return max( 2, min( 30, $sleep ) );
    }

    /**
     * 429 のうち、リトライしても解消しない「課金/クォータ枯渇」系エラーかを判定する。
     *
     * Gemini の "exceeded its monthly spending cap" / OpenAI の "exceeded current quota" /
     * Anthropic の "credit balance is too low" 等は、待っても直らずユーザーが管理画面で
     * 上限引き上げ・残高追加するまで継続する。
     *
     * @param string $message API レスポンス由来のエラーメッセージ
     * @return bool true = 永続エラー(リトライ不可)
     */
    public static function is_quota_exhausted_error( $message ) {
        if ( ! is_string( $message ) || $message === '' ) {
            return false;
        }
        $needles = array(
            'spending cap',
            'spend cap',
            'exceeded its monthly',
            'exceeded your current quota',
            'insufficient_quota',
            'billing_hard_limit_reached',
            'credit balance is too low',
            'plan_does_not_allow',
            'quota exceeded',
        );
        foreach ( $needles as $n ) {
            if ( stripos( $message, $n ) !== false ) {
                return true;
            }
        }
        return false;
    }

    /**
     * API エラー応答を種別判定し、リトライ可否とユーザー向けヒントを返す。
     *
     * 戻り値は array( 'kind' => string, 'retryable' => bool, 'hint' => string )。
     * `hint` は __() でラップされた翻訳済み文字列(空文字なら hint なし)。
     *
     * 判定順:
     *   1. quota_exhausted (message 内容で判定。429 を rate_limit に取られる前に評価する)
     *   2. auth_error (401 / 403)
     *   3. bad_request (400 / 404 / 422)
     *   4. rate_limit (429)
     *   5. server_error (408 / 500 / 502 / 503 / 504)
     *   6. unknown (上記いずれにも該当しない)
     *
     * @param int    $code    HTTP ステータスコード
     * @param string $message format_api_error_message() で生成済みのエラーメッセージ
     * @return array
     */
    public static function classify_api_error( $code, $message ) {
        $code    = (int) $code;
        $message = is_string( $message ) ? $message : '';

        // Bug fix (audit r9 self-audit): 旧 hint は 'Aborted after retries...' のように
        // 「最終結果」を述べる文章だった。しかし build_api_error_wp_error は
        // 個別 API 呼び出しが 1 回失敗するたびに毎回この hint を WP_Error に詰めるため、
        // リトライ 1 回目失敗時点のログにも「リトライ後に中止しました」と表示されて
        // しまい「まだリトライ中なのに中止表記」という自己矛盾が起きていた。
        // hint を「状態の説明」に書き換え、「中止」「Abort」は call_with_retry /
        // generate_with_retry が最終的に retry を諦めた段階で別途被せる方針に変更する。
        if ( self::is_quota_exhausted_error( $message ) ) {
            return array(
                'kind'      => 'quota_exhausted',
                'retryable' => false,
                /* translators: %s: provider label (e.g. "Claude", "OpenAI", "Gemini") */
                'hint'      => __( '%s: API spend cap or quota exceeded.', 'ai-auto-poster' ),
            );
        }
        if ( $code === 401 || $code === 403 ) {
            return array(
                'kind'      => 'auth_error',
                'retryable' => false,
                /* translators: %s: provider label */
                'hint'      => __( '%s: API authentication or permission error.', 'ai-auto-poster' ),
            );
        }
        if ( $code === 400 || $code === 404 || $code === 422 ) {
            return array(
                'kind'      => 'bad_request',
                'retryable' => false,
                /* translators: %s: provider label */
                'hint'      => __( '%s: Invalid request parameters.', 'ai-auto-poster' ),
            );
        }
        if ( $code === 429 ) {
            return array(
                'kind'      => 'rate_limit',
                'retryable' => true,
                /* translators: %s: provider label */
                'hint'      => __( '%s: Rate limit exceeded.', 'ai-auto-poster' ),
            );
        }
        if ( in_array( $code, array(
            408,
            500, 502, 503, 504,
            520, 521, 522, 523, 524, 525, 526, 527, // Cloudflare edge errors
            529,                                     // Anthropic Overloaded
        ), true ) ) {
            return array(
                'kind'      => 'server_error',
                'retryable' => true,
                /* translators: %s: provider label */
                'hint'      => __( '%s: Provider server error.', 'ai-auto-poster' ),
            );
        }
        return array(
            'kind'      => 'unknown',
            'retryable' => false,
            'hint'      => '',
        );
    }

    /**
     * WP_Error からログ向けメッセージを取り出す。
     *
     * UI 表示用メッセージ(短い日本語ヒント)に加え、build_api_error_wp_error が
     * error_data に格納した raw_message(英語の生エラー本文)があれば併記する。
     * 実行ログタブで原因を切り分けられるようにするためのヘルパー。
     *
     * @param WP_Error $err
     * @return string ログ向けメッセージ(UI ヒント + " | " + raw)
     */
    public static function format_error_for_log( $err ) {
        if ( ! is_wp_error( $err ) ) {
            return '';
        }
        $msg  = (string) $err->get_error_message();
        $data = $err->get_error_data();
        if ( is_array( $data ) && ! empty( $data['raw_message'] ) && is_string( $data['raw_message'] ) ) {
            $raw = $data['raw_message'];
            if ( $raw !== '' && stripos( $msg, $raw ) === false ) {
                return $msg . ' | ' . $raw;
            }
        }
        return $msg;
    }

    /**
     * `wp_remote_*` レスポンスから API エラー用の WP_Error を組み立てる
     *
     * data 配列に `http_code` と `retry_after` を含めることで、上位の
     * generate_with_retry がリトライ可否と待機時間を判断できる。
     *
     * @param string $prefix エラーコード接頭辞("claude_error" 等)
     * @param string $label  エラーメッセージの先頭ラベル("Claude" 等)
     * @param array  $response wp_remote_post の戻り値
     * @return WP_Error
     */
    public static function build_api_error_wp_error( $prefix, $label, $response ) {
        $code = (int) wp_remote_retrieve_response_code( $response );
        $body = (string) wp_remote_retrieve_body( $response );
        $data = json_decode( $body, true );
        $msg  = self::format_api_error_message( $code, $body, $data );
        // Retry-After ヘッダ(秒数 または HTTP-Date)。秒数のみ尊重し、
        // 数値以外(date 形式)は単純に既定の待機時間にフォールバックする。
        $retry_after_raw = wp_remote_retrieve_header( $response, 'retry-after' );
        $retry_after = is_numeric( $retry_after_raw ) ? max( 0, (int) $retry_after_raw ) : 0;

        // エラー種別を判定し、UI 表示メッセージは短いヒントのみにする。
        // 英語の生メッセージ($msg)は raw_message として error_data に保持し、実行ログに記録する。
        // unknown 種別(分類不能)時のみ生メッセージを画面に出す(他に手がかりがないため)。
        // i18n: hint はプロバイダ名($label)を %s で受け取る完結した文として翻訳する。
        // 文字列を連結するのではなく sprintf で組み立てることで、語順が異なる言語にも対応できる。
        $classified = self::classify_api_error( $code, $msg );
        if ( $classified['hint'] !== '' ) {
            $display_msg = sprintf( $classified['hint'], $label );
        } else {
            /* translators: 1: provider label (e.g. "Claude"), 2: raw API error message */
            $display_msg = sprintf( __( '%1$s: %2$s', 'ai-auto-poster' ), $label, $msg );
        }

        return new WP_Error( $prefix, $display_msg, array(
            'http_code'   => $code,
            'retry_after' => $retry_after,
            'retryable'   => $classified['retryable'],
            'kind'        => $classified['kind'],
            'raw_message' => $msg,
        ) );
    }

    /**
     * AIの応答からtitle, content, slugを抽出
     */
    private static function parse_response( $raw, $override_image_enabled = null ) {
        $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $raw );
        $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );
        $cleaned = trim( $cleaned );

        // 中括弧の対応バランスを取って最外周のJSONブロックを抽出
        // (greedy `\{[\s\S]*\}` だと content 内の {} が混じったときに壊れた範囲を切り出してしまう)
        $json_raw = self::extract_balanced_json( $cleaned );
        if ( $json_raw !== '' ) {
            $json_raw = self::normalize_quotes( $json_raw );

            // AI 出力が max_tokens 上限などで途中打ち切りされた可能性があれば補完を試みる。
            // (末尾が '}' でなく、文字列リテラル途中のままの場合: 閉じ '"' と '}' を補う)
            $json_raw = self::repair_truncated_json( $json_raw );

            // 1回目: そのままデコード
            $data = json_decode( $json_raw, true );

            // 2回目: 文字列値内の生改行を \n エスケープに置換してリトライ
            // (AIが content の中で生改行を返すとJSONが壊れるケースに対応)
            if ( ! is_array( $data ) ) {
                $json_fixed = self::escape_raw_newlines_in_json_strings( $json_raw );
                $data = json_decode( $json_fixed, true );
            }

            // 3回目: 文字列値内のエスケープされていない `"` を `\"` に変換してリトライ
            // (AIが content 内の HTML属性で生の `"` を書いてしまうケースに対応:
            //   "content":"<a href="https://..."> ← この href 値の `"` が JSON を破壊する)
            if ( ! is_array( $data ) ) {
                $json_fixed = self::escape_raw_newlines_in_json_strings( $json_raw );
                $json_fixed = self::escape_raw_quotes_in_json_strings( $json_fixed );
                $data = json_decode( $json_fixed, true );
            }

            if ( is_array( $data ) && isset( $data['title'] ) && isset( $data['content'] ) ) {
                $slug = isset( $data['slug'] ) ? sanitize_title( $data['slug'] ) : self::generate_slug_fallback( $data['title'] );
                $result = array(
                    'title'   => (string) $data['title'],
                    'content' => (string) $data['content'],
                    'slug'    => $slug,
                );

                // SEOフィールド（存在すれば抽出）
                // meta_description は SEO プラグイン(Yoast/Rank Math 等)の警告を避けるため、
                // 120 文字以上 156 文字以下を保証する。AI 出力が短い場合は本文先頭から補完する。
                $ai_meta = isset( $data['meta_description'] ) ? trim( (string) $data['meta_description'] ) : '';
                $result['meta_description'] = self::ensure_meta_description_length( $ai_meta, $result['content'] );
                if ( isset( $data['focus_keyword'] ) ) {
                    $result['focus_keyword'] = (string) $data['focus_keyword'];
                }
                if ( isset( $data['tags'] ) && is_array( $data['tags'] ) ) {
                    // AI が稀にネスト配列やオブジェクトを返すことがあるため、文字列のみに絞ってからサニタイズする
                    // (PHP 8+ で sanitize_text_field に配列を渡すと TypeError になるため)
                    $string_tags = array_filter( $data['tags'], 'is_string' );
                    $result['tags'] = array_values( array_map( 'sanitize_text_field', $string_tags ) );
                }
                if ( isset( $data['image_alt'] ) ) {
                    $result['image_alt'] = (string) $data['image_alt'];
                }
                // RSS pick モード: AI が返した記事インデックスを上位に伝搬する
                if ( isset( $data['selected_rss_index'] ) ) {
                    $result['selected_rss_index'] = (int) $data['selected_rss_index'];
                }
                // AIが書いた画像生成用プロンプト(英語)を受け取る
                // システムプロンプトに「Image Prompt: ...」等の画像プロンプト指示がある場合
                if ( isset( $data['image_prompt'] ) ) {
                    $result['image_prompt'] = trim( (string) $data['image_prompt'] );
                } elseif ( isset( $data['imagePrompt'] ) ) {
                    $result['image_prompt'] = trim( (string) $data['imagePrompt'] );
                } else {
                    // JSONキーになく、AI画像生成が有効な場合のみ content から抽出する。
                    // (AIの画像生成が無効な状況では、本文中の「Image Prompt:」風記述を
                    //  誤って削除しないようにする — 例: チュートリアル記事の本文)
                    // $override_image_enabled が non-null の場合はタスク/手動の per-post 設定を優先する。
                    $img_settings = class_exists( 'AIAP_Image_Client' ) ? AIAP_Image_Client::get_settings() : array();
                    $effective_enabled = is_null( $override_image_enabled )
                        ? ! empty( $img_settings['image_enabled'] )
                        : (bool) $override_image_enabled;
                    $is_ai_image  = $effective_enabled
                                 && in_array( ( $img_settings['image_source'] ?? '' ), array( 'dalle3', 'imagen', 'stability', 'flux' ), true );
                    if ( $is_ai_image ) {
                        $extracted_ip = self::extract_image_prompt_from_content( $result['content'] );
                        if ( $extracted_ip['prompt'] !== '' ) {
                            $result['image_prompt'] = $extracted_ip['prompt'];
                            $result['content']      = $extracted_ip['content'];
                        }
                    }
                }

                return $result;
            }

            // 3回目: JSONデコード完全失敗時、正規表現で title/content を個別抽出(最終フォールバック)
            $extracted = self::extract_title_content_by_regex( $json_raw );
            if ( $extracted ) {
                return array(
                    'title'           => $extracted['title'],
                    'content'         => $extracted['content'],
                    'slug'            => self::generate_slug_fallback( $extracted['title'] ),
                    'meta_description' => self::ensure_meta_description_length( '', $extracted['content'] ),
                );
            }
        }

        // JSONっぽい応答をパース失敗したまま投稿に流すと事故になるので拒否。
        // 条件①: 先頭が '{' (=従来挙動)
        // 条件②: 応答テキスト内に "title" と "content" の両キーが共存している
        //   → AI が前置き文・思考過程・コードブロックを書いていても、本来 JSON を
        //     返したかったことが明らかなので、汎用フォールバック(1行目 title 化)に流さない。
        // (raw 全文は AI レスポンスにユーザー入力プロンプトや潜在的機密情報が含まれる
        //  可能性があるため、メッセージには切り詰めた scrub 済み版を、data には含めない)
        $has_json_keys = ( strpos( $cleaned, '"title"' ) !== false
                        && strpos( $cleaned, '"content"' ) !== false );
        if ( ( $cleaned !== '' && $cleaned[0] === '{' ) || $has_json_keys ) {
            $hint = self::scrub_secrets( mb_substr( $raw, 0, 200 ) );
            /* translators: %s: short hint about which part of the response could not be parsed */
            return new WP_Error( 'parse_error', sprintf( __( 'Failed to parse AI response (JSON-like): %s', 'ai-auto-poster' ), $hint ) );
        }

        // Fallback
        $lines = explode( "\n", trim( $raw ) );
        $title = trim( array_shift( $lines ) );
        $title = preg_replace( '/^#+\s*/', '', $title );
        $content = trim( implode( "\n", $lines ) );

        if ( $title === '' || $content === '' ) {
            $hint = self::scrub_secrets( mb_substr( $raw, 0, 200 ) );
            /* translators: %s: short hint about which part of the response could not be parsed */
            return new WP_Error( 'parse_error', sprintf( __( 'Failed to parse AI response: %s', 'ai-auto-poster' ), $hint ) );
        }

        return array(
            'title'   => mb_substr( $title, 0, 100 ),
            'content' => wpautop( $content ),
            'slug'    => self::generate_slug_fallback( $title ),
        );
    }

    /**
     * JSON 内の文字列値に含まれる「エスケープされていない `"`」を `\"` に置換する。
     *
     * 想定するケース: AI が content 値の HTML 属性で生 `"` を返した時。
     *   入力: '"content":"<a href="https://example.com">link</a>"'
     *   出力: '"content":"<a href=\"https://example.com\">link</a>"'
     *
     * アルゴリズム:
     *   1. 文字列値の開始位置を特定 (`"key":"` の `"` 直後)
     *   2. 文字列値の終端候補を後続の構造文字 (`",`, `"}`, `"]`) で判定
     *   3. 終端より前にある `"` で、エスケープされていないものを `\"` に変換
     *
     * シンプル化のため、JSON のキー名は ASCII で書かれている前提
     * (Freemius や Anthropic の API はすべて英語キーで返すため安全)。
     *
     * @param string $json
     * @return string
     */
    private static function escape_raw_quotes_in_json_strings( $json ) {
        // JSON のキーを検出: `"key":` または `"key" :` パターン
        // キー名は ASCII の英数字とアンダースコア、ハイフンのみと仮定
        // 値の終端は次のキーの開始 `,"key":` または閉じ括弧 `}` `]` で判定する
        $result = '';
        $len    = strlen( $json );
        $i      = 0;

        while ( $i < $len ) {
            // キー検出: `"key"\s*:\s*` パターン
            if ( $json[ $i ] === '"' && preg_match( '/\G"([a-zA-Z_][a-zA-Z0-9_-]*)"\s*:\s*/A', $json, $m, 0, $i ) ) {
                $result .= $m[0];
                $i += strlen( $m[0] );

                // 値が文字列の場合: `"` で始まる
                if ( $i < $len && $json[ $i ] === '"' ) {
                    $result .= '"';
                    $i++;
                    // 値の本体: 次の終端 `"\s*[,}\]]` まで
                    $value_buf = '';
                    while ( $i < $len ) {
                        $ch = $json[ $i ];
                        if ( $ch === '\\' && $i + 1 < $len ) {
                            // 既存のエスケープシーケンスはそのまま
                            $value_buf .= $ch . $json[ $i + 1 ];
                            $i += 2;
                            continue;
                        }
                        if ( $ch === '"' ) {
                            // 終端候補: 次の非空白文字が , } ] のいずれかなら終端
                            $j = $i + 1;
                            while ( $j < $len && ( $json[ $j ] === ' ' || $json[ $j ] === "\t" || $json[ $j ] === "\n" || $json[ $j ] === "\r" ) ) {
                                $j++;
                            }
                            if ( $j >= $len || $json[ $j ] === ',' || $json[ $j ] === '}' || $json[ $j ] === ']' ) {
                                // 文字列終端
                                $result .= $value_buf . '"';
                                $i++;
                                break;
                            }
                            // 内部の生 `"` → \" にエスケープ
                            $value_buf .= '\\"';
                            $i++;
                            continue;
                        }
                        $value_buf .= $ch;
                        $i++;
                    }
                    continue;
                }
                // 値が文字列でない場合 (数値、配列、オブジェクト、bool、null) は触らずに次の文字へ
                continue;
            }
            $result .= $json[ $i ];
            $i++;
        }

        return $result;
    }

    /**
     * JSON文字列の値部分に含まれる「生の改行・タブ」を \n \t にエスケープする
     * AIが {"content": "...実際の改行..."} のように返したときにJSONパースを救済する
     */
    private static function escape_raw_newlines_in_json_strings( $json ) {
        $result    = '';
        $len       = strlen( $json );
        $in_string = false;
        $escape    = false;

        for ( $i = 0; $i < $len; $i++ ) {
            $ch = $json[ $i ];

            if ( $escape ) {
                $result .= $ch;
                $escape = false;
                continue;
            }

            if ( $ch === '\\' ) {
                $result .= $ch;
                $escape = true;
                continue;
            }

            if ( $ch === '"' ) {
                $in_string = ! $in_string;
                $result .= $ch;
                continue;
            }

            if ( $in_string ) {
                if ( $ch === "\n" ) {
                    $result .= '\\n';
                } elseif ( $ch === "\r" ) {
                    $result .= '\\r';
                } elseif ( $ch === "\t" ) {
                    $result .= '\\t';
                } else {
                    $result .= $ch;
                }
            } else {
                $result .= $ch;
            }
        }

        return $result;
    }

    /**
     * JSONパース完全失敗時の最終救済:
     * "title": "..." と "content": "..." を正規表現で個別抽出
     * 多少壊れたJSONでも記事本体を救う
     */
    private static function extract_title_content_by_regex( $json ) {
        // title: "..." (エスケープされた引用符に対応)
        if ( ! preg_match( '/"title"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"/s', $json, $tm ) ) {
            return null;
        }
        if ( ! preg_match( '/"content"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"/s', $json, $cm ) ) {
            return null;
        }
        $title   = self::decode_json_string_literal( $tm[1] );
        $content = self::decode_json_string_literal( $cm[1] );
        if ( $title === '' || $content === '' ) {
            return null;
        }
        return array( 'title' => $title, 'content' => $content );
    }

    /**
     * JSON文字列の中身(外側の引用符を除いた部分)を JSON仕様に従って復元する。
     *
     * stripcslashes は C言語のエスケープのみ対応で、JSON固有の `\uXXXX`(Unicode)
     * を復元できない。例: "あ" は stripcslashes だと "u3042" のまま壊れる。
     * json_decode に通すことで、\uXXXX・サロゲートペア・標準JSONエスケープを
     * すべて正しく復元する。
     *
     * 失敗時(不正な `\X` シーケンス等)は従来の stripcslashes にフォールバックし、
     * 既存挙動の互換性を保つ。
     *
     * @param string $raw 正規表現でキャプチャした JSON文字列の中身
     * @return string 復元後の文字列
     */
    private static function decode_json_string_literal( $raw ) {
        // 生制御文字(JSON文字列内では仕様上不正)を事前にエスケープしておかないと
        // json_decode が失敗する。正規表現は [^"\\] を許容しているため $raw に
        // 生改行・生タブが含まれる可能性がある。
        $escaped = strtr( $raw, array(
            "\n" => '\\n',
            "\r" => '\\r',
            "\t" => '\\t',
        ) );
        $decoded = json_decode( '"' . $escaped . '"' );
        if ( is_string( $decoded ) ) {
            return $decoded;
        }
        // フォールバック: 不正な `\X` シーケンスがある等で json_decode 失敗時は
        // 旧挙動の stripcslashes で救う(\uXXXX は失われるが、ASCII本文は救える)。
        return stripcslashes( $raw );
    }

    /**
     * meta_description の長さを 120-156 文字に保証する。
     *
     * - 156 文字超 → 切り詰め
     * - 120 文字未満 → 本文先頭(strip_tags 済)から補完して 120 文字以上 156 文字以下に整える
     * - 完全に空 → 本文先頭から 120-156 文字を切り出して返す
     *
     * SEO プラグイン(Yoast / Rank Math)が「meta description が短すぎる」と警告する
     * 推奨下限は概ね 120 文字なので、それを下回らないようにする。
     *
     * @param string $ai_meta AI が返した meta_description(trim 済を想定)
     * @param string $content 本文 HTML(タグはここで除去する)
     * @return string 120 〜 156 文字に整えられた meta_description
     */
    private static function ensure_meta_description_length( $ai_meta, $content ) {
        $min_len = 120;
        $max_len = 156;

        $body_text = trim( wp_strip_all_tags( (string) $content ) );
        // 改行・連続空白を 1 個の半角スペースに正規化(meta description で改行が無いことを保証)
        $body_text = preg_replace( '/\s+/u', ' ', $body_text );

        $meta = (string) $ai_meta;

        // 156 文字超は切り詰め
        if ( mb_strlen( $meta ) > $max_len ) {
            return mb_substr( $meta, 0, $max_len );
        }

        // 120 文字以上ならそのまま採用
        if ( mb_strlen( $meta ) >= $min_len ) {
            return $meta;
        }

        // 短すぎる(空も含む): 本文先頭から補完
        if ( $meta !== '' && $body_text !== '' ) {
            // 既存の AI meta + 区切り + 本文 で合成して切り出す
            $combined = $meta . ' ' . $body_text;
            return mb_substr( $combined, 0, $max_len );
        }

        // AI meta が空 → 本文だけを切り出す
        if ( $body_text !== '' ) {
            return mb_substr( $body_text, 0, $max_len );
        }

        // 本文も空(極めて稀): AI が返した値をそのまま返す(空でも構わない、SEO 機能側で扱う)
        return $meta;
    }

    /**
     * フォールバックスラグ生成
     */
    private static function generate_slug_fallback( $title ) {
        if ( preg_match( '/^[a-zA-Z0-9\s\-]+$/', $title ) ) {
            return sanitize_title( $title );
        }
        return 'post-' . wp_date( 'Ymd-His' );
    }

    /**
     * AI 出力が max_tokens 上限などで途中打ち切りされた JSON を補修する。
     *
     * 想定するケース:
     *   入力: '{"title":"...", "content":"記事の途中で切れて'   ← 末尾が文字列途中
     *   出力: '{"title":"...", "content":"記事の途中で切れて"}' ← 補完済み
     *
     * 既に閉じている JSON はそのまま返す(副作用なし)。
     *
     * @param string $json
     * @return string
     */
    private static function repair_truncated_json( $json ) {
        $len       = strlen( $json );
        if ( $len === 0 ) {
            return $json;
        }
        // Bug fix (audit r4): 旧実装は depth カウンタしか持たず `[` と `{` を同等に扱い、
        // 閉じも一律 `}` で append していた。たとえば `{"a":[1,2,` は depth=2 で終わるが
        // `}}` を補うと `{"a":[1,2,}}` となり依然 JSON 不正。今回 `{`/`[` をスタックに積み、
        // 閉じる時は対応する `}`/`]` を使う実装に置き換える。
        $stack     = array(); // 開いた括弧の対応する閉じ括弧を順に積む
        $in_string = false;
        $escape    = false;
        $negative_close = false; // 余分な閉じ括弧を検出した(=破損)

        for ( $i = 0; $i < $len; $i++ ) {
            $ch = $json[ $i ];
            if ( $escape ) { $escape = false; continue; }
            if ( $in_string ) {
                if ( $ch === '\\' ) { $escape = true; continue; }
                if ( $ch === '"' )  { $in_string = false; }
                continue;
            }
            if ( $ch === '"' ) { $in_string = true; continue; }
            if ( $ch === '{' ) { $stack[] = '}'; }
            elseif ( $ch === '[' ) { $stack[] = ']'; }
            elseif ( $ch === '}' || $ch === ']' ) {
                if ( empty( $stack ) ) {
                    $negative_close = true;
                } else {
                    array_pop( $stack );
                }
            }
        }

        // 完全に閉じている: 補修不要
        if ( ! $in_string && empty( $stack ) && ! $negative_close ) {
            return $json;
        }

        // 余分な閉じ括弧で構造が破綻している: 補修不能、原文を返して下流の json_decode に
        // 失敗扱いを任せる(無理に閉じ括弧を追加するとさらに壊れるため)。
        if ( $negative_close ) {
            return $json;
        }

        $repaired = $json;

        // 末尾の不完全な \ エスケープを除去 (例: "...test\" の "\")
        if ( substr( $repaired, -1 ) === '\\' ) {
            $repaired = substr( $repaired, 0, -1 );
        }

        // 文字列の途中で切れている: 閉じ引用符を補う
        if ( $in_string ) {
            $repaired .= '"';
        }

        // 文字列直後の不完全なトークン(例: `...,` や `...:`)を末尾から取り除く。
        // これがないと `{"a":[1,2,` を `]}` で閉じても `[1,2,]` となり JSON 不正。
        // ただし数値・true/false/null・"閉じ" は触らない(回数判定が複雑になるため)。
        $repaired = preg_replace( '/[,:]\s*$/', '', $repaired );

        // 開いたままの { や [ をスタックの逆順に閉じる(対応する括弧を使う)
        while ( ! empty( $stack ) ) {
            $repaired .= array_pop( $stack );
        }

        return $repaired;
    }

    /**
     * テキスト中の最初の '{' から、対応する '}' までを抽出する。
     * 文字列リテラル内の {} は無視し、エスケープも考慮するため、
     * greedy `/\{[\s\S]*\}/` のように余計な末尾までつかまない。
     *
     * @param string $text
     * @return string 抽出した JSON 候補文字列。見つからなければ ''。
     */
    private static function extract_balanced_json( $text ) {
        // ① 応答内の '"title"' を含む '{...}' ブロックを優先採用する。
        //   (AI が前置き文や Python コードを返した場合、その中の '{var}' を
        //    最初に見つけて誤抽出するのを防ぐ。'"content"' の同居も確認)
        $offset = 0;
        while ( ( $pos = strpos( $text, '"title"', $offset ) ) !== false ) {
            $brace = strrpos( substr( $text, 0, $pos ), '{' );
            if ( $brace !== false ) {
                $extracted = self::scan_balanced_from( $text, $brace );
                if ( $extracted !== '' && strpos( $extracted, '"content"' ) !== false ) {
                    return $extracted;
                }
            }
            $offset = $pos + 1;
        }
        // ② フォールバック: 最初の '{' から対応バランスを取る(従来挙動)
        $start = strpos( $text, '{' );
        if ( $start === false ) {
            return '';
        }
        return self::scan_balanced_from( $text, $start );
    }

    /**
     * 指定オフセットの '{' から対応する '}' までを切り出すスキャナ。
     * extract_balanced_json から共有される下請け処理。
     */
    private static function scan_balanced_from( $text, $start ) {
        if ( $start === false || $start < 0 ) {
            return '';
        }
        $len = strlen( $text );
        if ( $start >= $len ) {
            return '';
        }
        $depth     = 0;
        $in_string = false;
        $escape    = false;
        for ( $i = $start; $i < $len; $i++ ) {
            $ch = $text[ $i ];
            if ( $escape ) { $escape = false; continue; }
            if ( $in_string ) {
                if ( $ch === '\\' ) { $escape = true; continue; }
                if ( $ch === '"' )  { $in_string = false; }
                continue;
            }
            if ( $ch === '"' ) { $in_string = true; continue; }
            if ( $ch === '{' ) { $depth++; }
            elseif ( $ch === '}' ) {
                $depth--;
                if ( $depth === 0 ) {
                    return substr( $text, $start, $i - $start + 1 );
                }
            }
        }
        // 不対応(打ち切り等で } に到達しなかった): 開始位置から末尾までを返す。
        // この後、parse_response 側で repair_truncated_json() による閉じ括弧補完が行われる。
        return substr( $text, $start );
    }
}
