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

/**
 * AI provider helpers.
 */
class AIAP_AI_Providers {
    /**
     * Return model capability metadata for token parameter selection.
     */
    public static function get_model_capabilities( $provider, $model = '' ) {
        if ( $model === '' ) {
            $settings = AIAP_AI_Client::get_settings();
            $model    = $settings[ $provider . '_model' ] ?? '';
        }

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

        if ( $provider === 'gemini' ) {
            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,
                    'family'                  => 'openai-reasoning',
                );
            }
            return array(
                'token_param'             => 'max_tokens',
                'supports_system_message' => true,
                'family'                  => 'openai-chat',
            );
        }

        if ( $provider === 'openrouter' && self::is_openrouter_openai_reasoning_model( $model ) ) {
            return array(
                'token_param'             => 'max_completion_tokens',
                'supports_system_message' => true,
                'family'                  => 'openai-reasoning',
            );
        }

        return array(
            'token_param'             => 'max_tokens',
            'supports_system_message' => true,
            'family'                  => 'openai-compatible',
        );
    }

    /**
     * OpenAI の reasoning 系モデルかどうかを判定する。
     *
     * 判定ロジックは AIAP_Model_Registry に集約し、動的取得モデルや
     * ユーザー入力のモデル ID にも同じルールを適用する。
     */
    public static function is_openai_reasoning_model( $model ) {
        return AIAP_Model_Registry::is_reasoning_model( 'openai', $model );
    }

    private static function is_openrouter_openai_reasoning_model( $model ) {
        if ( ! is_string( $model ) || $model === '' ) {
            return false;
        }

        $normalized = preg_replace( '#^openai/#', '', strtolower( $model ) );
        return AIAP_Model_Registry::is_reasoning_model( 'openai', $normalized );
    }

    /**
     * OpenAI 互換プロバイダのエンドポイント定義。
     *
     * Chat Completions と Models API の URL、API キー設定名、モデル設定名を
     * ここに集約し、動的モデル取得や疎通テストでも同じ定義を使う。
     */
    public static function get_openai_compatible_endpoints() {
        return array(
            'mistral' => array(
                'url'        => 'https://api.mistral.ai/v1/chat/completions',
                'models_url' => 'https://api.mistral.ai/v1/models',
                'key'        => 'mistral_api_key',
                'model'      => 'mistral_model',
            ),
            'groq' => array(
                'url'        => 'https://api.groq.com/openai/v1/chat/completions',
                'models_url' => 'https://api.groq.com/openai/v1/models',
                'key'        => 'groq_api_key',
                'model'      => 'groq_model',
            ),
            'deepseek' => array(
                'url'        => 'https://api.deepseek.com/chat/completions',
                'models_url' => 'https://api.deepseek.com/v1/models',
                'key'        => 'deepseek_api_key',
                'model'      => 'deepseek_model',
            ),
            'openrouter' => array(
                'url'        => 'https://openrouter.ai/api/v1/chat/completions',
                'models_url' => 'https://openrouter.ai/api/v1/models',
                'key'        => 'openrouter_api_key',
                'model'      => 'openrouter_model',
            ),
        );
    }

    /**
     * API キー設定フィールド名の一覧を返す。
     */
    public static function get_key_fields() {
        return array_values( array_filter( array_map( array( __CLASS__, 'get_key_field' ), self::get_providers() ) ) );
    }

    public static function get_key_field( $provider ) {
        $provider = (string) $provider;
        $base = array(
            'claude' => 'claude_api_key',
            'openai' => 'openai_api_key',
            'gemini' => 'gemini_api_key',
        );
        if ( isset( $base[ $provider ] ) ) {
            return $base[ $provider ];
        }
        $endpoints = self::get_openai_compatible_endpoints();
        return isset( $endpoints[ $provider ]['key'] ) ? $endpoints[ $provider ]['key'] : '';
    }

    public static function get_model_field( $provider ) {
        $provider = (string) $provider;
        $base = array(
            'claude' => 'claude_model',
            'openai' => 'openai_model',
            'gemini' => 'gemini_model',
        );
        if ( isset( $base[ $provider ] ) ) {
            return $base[ $provider ];
        }
        $endpoints = self::get_openai_compatible_endpoints();
        return isset( $endpoints[ $provider ]['model'] ) ? $endpoints[ $provider ]['model'] : '';
    }

    // ================================================================
    // Provider / Model listing & caching
    // ================================================================

    /**
     * テキスト生成プロバイダの一覧を返す。
     */
    public static function get_providers() {
        return array( 'claude', 'openai', 'gemini', 'mistral', 'groq', 'deepseek', 'openrouter' );
    }

    /**
     * 動的取得したモデル一覧を保存する transient の設定。
     */
    const MODEL_CACHE_PREFIX = 'aiap_models_';
    // Bug fix: 旧 DAY_IN_SECONDS は AIAP_Model_Sync の weekly cron と整合しておらず、
    // cron 直後の 24h だけ動的取得モデルが UI ドロップダウンに出て、残り 6 日は
    // Registry 静的既定値に戻るちぐはぐな挙動を生んでいた。週次 cron に合わせて
    // WEEK_IN_SECONDS にする。手動 Refresh 経路は同じ transient を上書きするため
    // 影響なし。
    const MODEL_CACHE_TTL = WEEK_IN_SECONDS;

    /**
     * 指定プロバイダのモデル一覧を返す。
     *
     * 動的取得済みのキャッシュがあれば優先し、なければレジストリの既定値に戻す。
     */
    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 = AIAP_AI_Client::get_settings();
        $endpoints = self::get_openai_compatible_endpoints();
        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':
            case 'groq':
            case 'deepseek':
            case 'openrouter':
                $key_field = self::get_key_field( $provider );
                $models_url = isset( $endpoints[ $provider ]['models_url'] ) ? $endpoints[ $provider ]['models_url'] : '';
                return self::fetch_openai_compatible_models( $settings[ $key_field ] ?? '', $models_url );
            case 'dalle3':
                if ( class_exists( 'AIAP_Image_Client' ) ) {
                    $image_settings = AIAP_Image_Client::get_settings();
                    $key_field = AIAP_Image_Client::get_key_field( 'dalle3' );
                    return self::fetch_openai_models( $key_field !== '' ? ( $image_settings[ $key_field ] ?? '' ) : '', 'image' );
                }
                return AIAP_AI_Errors::no_api_key_error( 'OpenAI' );
            case 'imagen':
                if ( class_exists( 'AIAP_Image_Client' ) ) {
                    $image_settings = AIAP_Image_Client::get_settings();
                    $key_field = AIAP_Image_Client::get_key_field( 'imagen' );
                    return self::fetch_gemini_models( $key_field !== '' ? ( $image_settings[ $key_field ] ?? '' ) : '', 'image' );
                }
                return AIAP_AI_Errors::no_api_key_error( 'Gemini' );
        }
        /* translators: %s: provider key */
        return new WP_Error( 'unsupported_provider', sprintf( __( 'Dynamic fetch not supported for: %s', 'ai-auto-poster' ), $provider ) );
    }

    /**
     * モデル取得 API の共通前処理。
     *
     * API キー検証、GET 実行、HTTP エラー処理、レスポンス配列の取り出しをまとめる。
     *
     * @param string $api_key API キー。
     * @param string $provider_label ログ・エラー表示用のプロバイダ名。
     * @param string $url Models API の URL。
     * @param array  $headers 追加ヘッダー。
     * @param string $list_key レスポンス内でモデル配列が入っているキー。
     * @return array|WP_Error モデル配列、または取得エラー。
     */
    private static function fetch_models_preamble( $api_key, $provider_label, $url, $headers, $list_key ) {
        if ( empty( $api_key ) ) {
            return AIAP_AI_Errors::no_api_key_error( $provider_label );
        }
        $r = AIAP_Http::get( $url, $headers );
        if ( is_wp_error( $r ) ) {
            return $r;
        }
        if ( $r['code'] !== 200 ) {
            return new WP_Error(
                'api_error',
                /* translators: 1: provider label, 2: HTTP status code */
                sprintf( __( '%1$s API: HTTP %2$d', 'ai-auto-poster' ), $provider_label, $r['code'] )
            );
        }
        $data = is_array( $r['json'] ) ? $r['json'] : array();
        if ( empty( $data[ $list_key ] ) || ! is_array( $data[ $list_key ] ) ) {
            return new WP_Error( 'empty_response', __( 'No models returned', 'ai-auto-poster' ) );
        }
        return $data[ $list_key ];
    }

    private static function fetch_claude_models( $api_key ) {
        $models = self::fetch_models_preamble(
            $api_key, 'Claude',
            'https://api.anthropic.com/v1/models',
            array( 'x-api-key' => $api_key, 'anthropic-version' => '2023-06-01' ),
            'data'
        );
        if ( is_wp_error( $models ) ) {
            return $models;
        }
        $result = array();
        foreach ( $models 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' ) {
        $models = self::fetch_models_preamble(
            $api_key, 'OpenAI',
            'https://api.openai.com/v1/models',
            AIAP_Http::bearer( $api_key ),
            'data'
        );
        if ( is_wp_error( $models ) ) {
            return $models;
        }
        $result = array();
        foreach ( $models as $m ) {
            $id = $m['id'] ?? '';
            if ( $id === '' ) continue;
            if ( $kind === 'text' ) {
                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' ) {
        $url = 'https://generativelanguage.googleapis.com/v1beta/models?key=' . rawurlencode( (string) $api_key );
        $models = self::fetch_models_preamble(
            $api_key, 'Gemini',
            $url,
            array(),
            'models'
        );
        if ( is_wp_error( $models ) ) {
            return $models;
        }
        $result = array();
        foreach ( $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 AIAP_AI_Errors::no_api_key_error( '' );
        }
        $r = AIAP_Http::get( $url, AIAP_Http::bearer( $api_key ) );
        if ( is_wp_error( $r ) ) {
            return $r;
        }
        if ( $r['code'] !== 200 ) {
            /* translators: %d: HTTP status code returned by the upstream API */
            return new WP_Error( 'api_error', sprintf( __( 'HTTP %d', 'ai-auto-poster' ), $r['code'] ) );
        }
        $data = is_array( $r['json'] ) ? $r['json'] : array();
        $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;
            if ( preg_match( '/(embedding|audio|whisper|tts|moderation|rerank|search|vision)/i', $id ) ) continue;
            $result[ $id ] = $id;
        }
        ksort( $result );
        return $result;
    }

    /**
     * 全プロバイダの既定モデル選択肢を返す。
     *
     * テキスト・画像モデルの定義は AIAP_Model_Registry に集約されているため、
     * ここでは UI 用の配列へ組み立てるだけにする。
     *
     * @return array provider_key => array( model_id => display_label )
     */
    public static function get_model_options() {
        $result = array();

        $text_providers = AIAP_Model_Registry::text_providers();
        foreach ( $text_providers as $provider => $_def ) {
            $result[ $provider ] = AIAP_Model_Registry::text_model_options( $provider );
        }

        $result['dalle3'] = AIAP_Model_Registry::image_model_options( 'dalle3' );
        $result['imagen'] = AIAP_Model_Registry::image_model_options( 'imagen' );

        return $result;
    }

    /**
     * 記事生成時の出力トークン上限を返す。
     *
     * 通常モデルでは本文長に応じて控えめな上限を使う。reasoning 系モデルは
     * 思考トークンも同じ上限から消費するため、空応答を避ける目的で 24000 を使う。
     *
     * 詳細: docs/superpowers/specs/2026-05-19-openai-reasoning-token-limit-design.md
     *
     * @param array  $settings AI settings.
     * @param string $provider Provider key.
     * @param string $model Model ID.
     * @return int
     */
    public static function get_output_token_limit( $settings, $provider = '', $model = '' ) {
        // 基本方針 (audit r12): 通常モデルは全プロバイダ一律 16000。
        // max_tokens は「上限」であり目標値ではない。AI は指示文 (約 N 文字 +
        // MUST be between min and max) に従って指定長で完結し、早く終われば
        // 16000 未満でも問題ない。
        //   - 主要モデル (gpt-4.1-mini / gemini-flash / claude-haiku / claude-sonnet 等) を
        //     概ねカバー
        //   - 日本語 5000 字 (≈ 15000 tokens) の本文出力にも対応
        //   - HTTP timeout 120 秒に対し、実出力は指示文通り完結するため実生成時間は変わらない
        //
        // 例外: Reasoning 系モデル (OpenAI gpt-5*/o1/o3/o4、Mistral magistral、
        // DeepSeek v4-pro、OpenRouter 経由の各種 reasoning モデル等)。
        // これらは reasoning(思考)トークンを出力トークン上限の枠から「合算」して
        // 消費する仕様のため、通常モデルと同じ 16000 では思考だけで枠を使い切り、
        // 本文が空 (finish_reason: length) または極端に短くなる。日本語のように
        // マルチバイトで出力トークンも嵩む言語では特に発生しやすい。
        // (実ログで gpt-5 が 4 回連続 finish_reason: length で生成失敗するのを確認。
        //  詳細: docs/superpowers/specs/2026-05-19-openai-reasoning-token-limit-design.md)
        //
        // 判定はプロバイダ非依存の AIAP_Model_Registry::is_reasoning_model() に集約する。
        // 旧実装は OpenAI ($provider==='openai') 限定だったため、同じ reasoning 特性を
        // 持つ Mistral/DeepSeek/OpenRouter の reasoning モデルが flat 16000 のまま取り
        // 残されていた (全 AI 横展開漏れ)。プロバイダを問わず reasoning なら枠を広げる。
        //
        // 対策: reasoning 系のみ content_length に依存しない十分大きい枠を返す。
        // 設計書の根拠値 24000 (= 16000 の 1.5 倍、思考数千トークン + 長め本文に余裕) を
        // 下限とし、ユーザー指定文字数がそれを上回る大記事では本文必要量に連動させる。
        // 本文必要量の目安: 目標文字数 (CJK は概ね 1 文字 ≈ 1 token) の 1.5 倍 (上振れ/
        // タグ分の余裕) + 思考用バッファ 16000。
        if ( $model !== '' && class_exists( 'AIAP_Model_Registry' )
            && AIAP_Model_Registry::is_reasoning_model( $provider, $model ) ) {
            $reasoning_length = is_array( $settings ) && isset( $settings['content_length'] )
                ? max( 0, (int) $settings['content_length'] )
                : 0;
            $reasoning_limit = 24000;
            if ( $reasoning_length > 0 ) {
                $reasoning_limit = max(
                    $reasoning_limit,
                    (int) round( $reasoning_length * 1.5 ) + 16000
                );
            }
            return $reasoning_limit;
        }

        return 16000;
    }

    /**
     * Dedup: OpenAI 互換 chat completions レスポンスから content を抽出する共通ヘルパ。
     *
     * 旧実装は call_openai と call_openai_compatible で同じ「content 取得 + 空文字時の
     * finish_reason 抽出 + WP_Error 生成」を独立に書いていた。
     *
     * @param array  $data     /chat/completions の JSON
     * @param string $provider_label 'OpenAI' / 'Mistral' / 'OpenRouter' 等のラベル
     * @return string|WP_Error 成功時 content 文字列、空応答時は WP_Error('empty_ai_response')
     */
    private static function extract_openai_chat_content( array $data, $provider_label ) {
        $content = self::scalar_text( $data['choices'][0]['message']['content'] ?? null );
        $finish  = isset( $data['choices'][0]['finish_reason'] ) ? (string) $data['choices'][0]['finish_reason'] : 'unknown';

        // Bug fix (audit r12): finish_reason='length' (= max_tokens 到達による途中切れ) を
        // content が空でなくても検出する。旧実装は「content が空のときだけ」 finish_reason を
        // 見ていたため、途中で切れた JSON 文字列がそのままパーサに渡り「JSON 類似形式」エラー
        // として表面化していた。早期に WP_Error を返すことで:
        //   - 切れた JSON を WP に保存する事故を防ぐ
        //   - retryable=true でリトライをトリガーし、次回の試行で完全な JSON 取得を狙う
        // OpenAI 本家は finish_reason='length' だが、OpenAI 互換プロバイダ
        // (Mistral/Groq/DeepSeek/OpenRouter) は上流の値をそのまま透過して
        // 'max_tokens' / 'MAX_TOKENS' / 'length' など表記がぶれることがある。
        // 'length' 完全一致のみだと途中切れを取りこぼし、短い本文がそのまま
        // 通過する。大文字小文字を無視し、length / max_tokens の両表記を検出する。
        $finish_norm = strtolower( $finish );
        if ( $finish_norm === 'length' || $finish_norm === 'max_tokens' ) {
            return new WP_Error(
                'parse_error',
                sprintf(
                    /* translators: 1: AI provider label, 2: finish_reason returned by the API */
                    __( '%1$s response was truncated by max_tokens limit (finish_reason: %2$s)', 'ai-auto-poster' ),
                    $provider_label,
                    $finish
                ),
                array( 'retryable' => true, 'kind' => 'max_tokens_truncated' )
            );
        }

        if ( trim( $content ) !== '' ) {
            return $content;
        }
        return new WP_Error(
            'empty_ai_response',
            sprintf(
                /* translators: 1: AI provider label, 2: finish_reason */
                __( '%1$s returned empty response (finish_reason: %2$s)', 'ai-auto-poster' ),
                $provider_label,
                $finish
            ),
            // 空応答もタイミング次第で次回成功する可能性があるためリトライ可能扱い。
            array( 'retryable' => true, 'kind' => 'empty_response' )
        );
    }

    /**
     * AI 応答から取り出した本文値を安全に文字列化する。
     *
     * 一部の OpenAI 互換プロバイダは message.content を文字列ではなく
     * content parts の配列で返すことがある。配列を (string) キャストすると
     * PHP Warning「Array to string conversion」が発生し、本文がリテラル
     * "Array" になって下流のパーサに渡ってしまう。スカラー以外は空文字に
     * 倒し、各抽出メソッドの空応答ハンドラ (empty_ai_response, retryable)
     * に委ねることで、不正な本文が投稿に保存される事故を防ぐ。
     *
     * 本体経路 (call_openai/call_claude/call_gemini) と dedup の mini 呼び出し
     * (AIAP_Dedup) の双方から使う共通ヘルパのため public にしている。
     *
     * @param mixed $value 応答 JSON から取り出した本文候補
     * @return string スカラーなら文字列化した値、それ以外は空文字
     */
    public static function scalar_text( $value ) {
        return is_scalar( $value ) ? (string) $value : '';
    }

    /**
     * content parts 配列 (Gemini candidates[].content.parts、または OpenAI 互換の
     * message.content 配列形式) から text を全て連結して返す。
     *
     * Gemini は長文や thinking 応答で本文を複数 parts に分割する。parts[0] だけを
     * 読むと本文後半が欠落し「指定文字数に足りない」原因になるため、全 part の
     * text を順に連結する。各要素は scalar_text() で安全に文字列化し、非スカラーや
     * text キーの無い part (functionCall 等) はスキップする。
     *
     * @param mixed $parts content parts 配列 (非配列なら空文字を返す)
     * @return string 連結した本文。取り出せなければ空文字
     */
    public static function join_text_parts( $parts ) {
        if ( ! is_array( $parts ) ) {
            return '';
        }
        $buf = '';
        foreach ( $parts as $part ) {
            if ( is_array( $part ) && isset( $part['text'] ) ) {
                $buf .= self::scalar_text( $part['text'] );
            }
        }
        return $buf;
    }

    public static function build_openai_chat_completion_body( $model, array $messages, array $caps, $output_tokens = null ) {
        $body = array(
            'model'    => $model,
            'messages' => $messages,
        );

        // Bug fix: OpenAI o1 ファミリは response_format を受け付けず 400 を返す。
        // Registry の supports_json_response_format で対応モデルのみ送る。
        if ( class_exists( 'AIAP_Model_Registry' ) && AIAP_Model_Registry::supports_json_response_format( '', $model ) ) {
            $body['response_format'] = array( 'type' => 'json_object' );
        }

        if ( $output_tokens !== null && ! empty( $caps['token_param'] ) ) {
            $body[ $caps['token_param'] ] = (int) $output_tokens;
        }

        return $body;
    }

    private static function build_openai_chat_body( $model, $system, $prompt, array $caps, $output_tokens ) {
        return self::build_openai_chat_completion_body(
            $model,
            array(
                array( 'role' => 'system', 'content' => $system ),
                array( 'role' => 'user',   'content' => $prompt ),
            ),
            $caps,
            $output_tokens
        );
    }

    // ================================================================
    // API calls (provider-specific)
    // ================================================================

    /**
     * Claude API を呼び出す。
     */
    public static function call_claude( $settings, $system, $prompt ) {
        if ( empty( $settings['claude_api_key'] ) ) {
            return AIAP_AI_Errors::no_api_key_error( 'Claude' );
        }
        $output_tokens = self::get_output_token_limit( $settings );

        $r = AIAP_Http::post_json( 'https://api.anthropic.com/v1/messages', array(
            'model'      => $settings['claude_model'],
            'max_tokens' => $output_tokens,
            'system'     => $system,
            'messages'   => array(
                array( 'role' => 'user',      'content' => $prompt ),
                array( 'role' => 'assistant', 'content' => '{' ),
            ),
        ), array(
            'x-api-key'         => $settings['claude_api_key'],
            'anthropic-version' => '2023-06-01',
        ) );

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

        $code = $r['code'];
        $data = is_array( $r['json'] ) ? $r['json'] : array();

        if ( $code !== 200 ) {
            return AIAP_AI_Errors::build_api_error_wp_error( 'claude_error', 'Claude', $r['response'] );
        }

        // Claude も content を複数ブロックの配列で返す。通常はプレフィル '{' により
        // text ブロック 1 個だが、将来的な複数ブロック化に備え Gemini と同じく全 text
        // ブロックを連結して取りこぼしを防ぐ (parts[0] のみ読む実装との横展開統一)。
        $content     = self::join_text_parts( $data['content'] ?? null );
        $stop_reason = isset( $data['stop_reason'] ) ? (string) $data['stop_reason'] : 'unknown';

        // Bug fix (audit r12): stop_reason='max_tokens' (= max_tokens 到達による途中切れ) を
        // content が空でなくても検出する。OpenAI / Gemini 抽出と同じ理由。
        if ( $stop_reason === 'max_tokens' ) {
            return new WP_Error(
                'parse_error',
                __( 'Claude response was truncated by max_tokens limit (stop_reason: max_tokens)', 'ai-auto-poster' ),
                array( 'retryable' => true, 'kind' => 'max_tokens_truncated' )
            );
        }

        if ( trim( $content ) === '' ) {
            /* 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 ),
                // 空応答はタイミング次第で次回成功する可能性があるためリトライ可能扱い。
                array( 'retryable' => true, 'kind' => 'empty_response' )
            );
        }
        return '{' . $content;
    }

    /**
     * OpenAI API を呼び出す。
     *
     * モデルごとに正しいトークン上限パラメータを選ぶため、
     * get_model_capabilities() の token_param を必ず経由する。
     */
    public static function call_openai( $settings, $system, $prompt ) {
        if ( empty( $settings['openai_api_key'] ) ) {
            return AIAP_AI_Errors::no_api_key_error( 'OpenAI' );
        }

        $caps = self::get_model_capabilities( 'openai', $settings['openai_model'] );
        $output_tokens = self::get_output_token_limit( $settings, 'openai', $settings['openai_model'] );
        $body = self::build_openai_chat_body( $settings['openai_model'], $system, $prompt, $caps, $output_tokens );

        $r = AIAP_Http::post_json( 'https://api.openai.com/v1/chat/completions', $body, AIAP_Http::bearer( $settings['openai_api_key'] ) );

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

        $code = $r['code'];
        $data = is_array( $r['json'] ) ? $r['json'] : array();

        if ( $code !== 200 ) {
            return AIAP_AI_Errors::build_api_error_wp_error( 'openai_error', 'OpenAI', $r['response'] );
        }

        return self::extract_openai_chat_content( $data, 'OpenAI' );
    }

    /**
     * Gemini API を呼び出す。
     */
    public static function call_gemini( $settings, $system, $prompt ) {
        if ( empty( $settings['gemini_api_key'] ) ) {
            return AIAP_AI_Errors::no_api_key_error( 'Gemini' );
        }
        $output_tokens = self::get_output_token_limit( $settings );

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

        $r = AIAP_Http::post_json( $url, array(
            'system_instruction' => array(
                'parts' => array( array( 'text' => $system ) ),
            ),
            'contents' => array(
                array(
                    'role'  => 'user',
                    'parts' => array( array( 'text' => $prompt ) ),
                ),
            ),
            'generationConfig' => array(
                'maxOutputTokens' => $output_tokens,
                'responseMimeType' => 'application/json',
            ),
        ) );

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

        $code = $r['code'];
        $data = is_array( $r['json'] ) ? $r['json'] : array();

        if ( $code !== 200 ) {
            return AIAP_AI_Errors::build_api_error_wp_error( 'gemini_error', 'Gemini', $r['response'] );
        }

        // Gemini は本文を複数 parts に分割して返すことがある (特に thinking 系や長文)。
        // 旧実装は parts[0] のみを読み、parts[1..n] を黙って捨てていたため、長い
        // 日本語記事が先頭 part までで途切れ「指定文字数に足りない」原因になっていた。
        // 全 part の text を連結して本文全体を取り出す。
        $content = self::join_text_parts( $data['candidates'][0]['content']['parts'] ?? null );
        $finish  = isset( $data['candidates'][0]['finishReason'] ) ? (string) $data['candidates'][0]['finishReason'] : 'unknown';

        // Bug fix (audit r12): finishReason='MAX_TOKENS' (= maxOutputTokens 到達による途中切れ) を
        // content が空でなくても検出する。OpenAI 抽出と同じ理由で、途中切れ JSON が
        // パーサに渡って「JSON 類似形式」エラーになる経路を塞ぐ。
        if ( $finish === 'MAX_TOKENS' ) {
            return new WP_Error(
                'parse_error',
                __( 'Gemini response was truncated by maxOutputTokens limit (finishReason: MAX_TOKENS)', 'ai-auto-poster' ),
                array( 'retryable' => true, 'kind' => 'max_tokens_truncated' )
            );
        }

        if ( trim( $content ) === '' ) {
            /* 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 ),
                // 空応答はタイミング次第で次回成功する可能性があるためリトライ可能扱い。
                array( 'retryable' => true, 'kind' => 'empty_response' )
            );
        }
        return $content;
    }

    /**
     * OpenAI 互換 API を呼び出す。
     *
     * Mistral / Groq / DeepSeek / OpenRouter を共通の Chat Completions 形式で扱う。
     */
    public 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 AIAP_AI_Errors::no_api_key_error( ucfirst( $provider ) );
        }

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

        $caps = self::get_model_capabilities( $provider, $model );
        $output_tokens = self::get_output_token_limit( $settings, $provider, $model );
        $body = self::build_openai_chat_body( $model, $system, $prompt, $caps, $output_tokens );

        unset( $headers['Content-Type'] );

        $r = AIAP_Http::post_json( $ep['url'], $body, $headers );

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

        $code = $r['code'];
        $data = is_array( $r['json'] ) ? $r['json'] : array();

        if ( $code !== 200 ) {
            return AIAP_AI_Errors::build_api_error_wp_error( $provider . '_error', ucfirst( $provider ), $r['response'] );
        }

        return self::extract_openai_chat_content( $data, ucfirst( $provider ) );
    }
}
