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

/**
 * アイキャッチ画像の取得を担当するクラス
 *
 * AI生成: DALL-E 3 (OpenAI) / Imagen (Gemini) / Stability AI
 * フリー素材: Unsplash / Pixabay / Pexels
 */
class AIAP_Image_Client {

    const OPTION_KEY = 'aiap_image_settings';

    /**
     * Imagen フォールバック優先順位
     *
     * 保存モデルが Google API の Imagen モデル一覧に含まれない場合、このリストを上から順に走査し、
     * 最初にマッチしたものを採用する。リストにマッチが無ければ、API が返した imagen-* モデルの
     * ID 文字列降順で 1 位を採用する(新モデルへの自動追従)。
     *
     * 値は実装時点での想定であり、Google 側のリリース状況に応じて更新する。
     */
    const IMAGEN_FALLBACK_PRIORITY = array(
        'imagen-4.0-generate-001',
        'imagen-4.0-fast-generate-001',
        'imagen-3.0-generate-002',
        'imagen-3.0-generate-001',
    );

    /**
     * 画像 API 呼び出しの最大試行回数(初回 + リトライ)。
     * テキスト AI 側の AIAP_AI_Client::GENERATE_MAX_RETRY と対称な仕様。
     */
    const IMAGE_GENERATE_MAX_RETRY = 3;

    /**
     * 画像 API 呼び出し用のリトライラッパー。
     *
     * テキスト AI 側の AIAP_AI_Client::generate_with_retry() と同じ仕様で動く:
     * - 最大 IMAGE_GENERATE_MAX_RETRY 回まで試行
     * - 失敗時、WP_Error の error_data を見てリトライ可否を判断
     *   - retryable=false (永続エラー: 課金枯渇/認証/入力不正) は即返却
     *   - 既存の is_retryable_http_code でも判定 (後方互換)
     * - retry_after があればそれを尊重 (上限 30 秒)、無ければ線形バックオフ (1, 2 秒)
     * - 各リトライ前に $on_retry($attempt, $err) を呼ぶ (ログ用)
     *
     * @param callable $fn        実 API 呼び出しを行う無引数関数。array(成功) or WP_Error(失敗) を返す
     * @param callable|null $on_retry function(int $attempt, WP_Error $err): void
     * @return array|WP_Error 最後の成功結果、または最終失敗の WP_Error
     */
    public static function call_with_retry( $fn, $on_retry = null ) {
        $result = null;
        // IMAGE_GENERATE_MAX_RETRY は「リトライ最大回数」。よって試行は最大 (N+1) 回(初回 + リトライ N 回)。
        for ( $attempt = 1; $attempt <= self::IMAGE_GENERATE_MAX_RETRY + 1; $attempt++ ) {
            // Bug fix (audit r9 self-audit): 各 attempt の頭で実行時間制限をリセットする。
            // set_time_limit(0) は呼ぶたびにカウントを 0 に戻す効果がある。
            // 上位 (ajax_run_now_step / Scheduler::run) で既に呼んでいるが、共用ホストで
            // disable_functions により無視されていた場合や、PHP-FPM の request_terminate_timeout
            // が PHP の制限とは別軸で効いている場合の保険。各リトライの前に無制限へリセットすれば、
            // 少なくとも個々の API 呼び出しは max_execution_time 切れで殺されにくくなる。
            if ( function_exists( 'set_time_limit' ) ) {
                @set_time_limit( 0 );
            }

            $result = call_user_func( $fn );
            if ( ! is_wp_error( $result ) ) {
                return $result;
            }

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

            // 永続エラー(retryable=false)は即返却。
            if ( is_array( $err_data ) && array_key_exists( 'retryable', $err_data ) && ! $err_data['retryable'] ) {
                return $result;
            }

            // retryable キーが無い経路の後方互換チェック。
            // http_code=0(HTTP 通信失敗)はリトライ可能扱い。
            if ( $http_code !== 0 && ! AIAP_AI_Client::is_retryable_http_code( $http_code ) ) {
                return $result;
            }

            // 最後の試行はリトライ前処理(コールバック・sleep)をスキップ。
            if ( $attempt <= self::IMAGE_GENERATE_MAX_RETRY ) {
                if ( is_callable( $on_retry ) ) {
                    call_user_func( $on_retry, $attempt, $result );
                }
                // 待機秒数は AI_Client::compute_retry_sleep_seconds() に集約 (#35)。
                sleep( AIAP_AI_Client::compute_retry_sleep_seconds( $attempt, $retry_after ) );
            }
        }
        return $result;
    }

    /**
     * Featured image mode + global default → effective enabled boolean.
     *
     * タスク/手動の featured_image_mode ('on'|'off') から
     * 「実際にアイキャッチを付けるか」を一意に決定する。
     *
     * 仕様変更履歴:
     *   - 初期: 'inherit'/'on'/'off' の 3 値、グローバル「デフォルト画像ソースとして使用する」トグル併用
     *   - 旧:   グローバルトグル廃止、'inherit' は常時 true (画像を付ける) にフォールバック
     *   - 現:   'inherit' を完全廃止し 'on'/'off' の 2 値化 (旧 inherit データは migration で 'on' に変換)
     * 防御として未知値は 'on' 相当 (true) を返す。
     *
     * @param string $mode           'on' | 'off' (旧 'inherit' も true 扱い: 防御)
     * @param mixed  $global_enabled (廃止予定、互換のため引数は残す) 旧 image_enabled
     * @return bool
     */
    public static function resolve_image_enabled( $mode, $global_enabled = 1 ) {
        if ( $mode === 'off' ) { return false; }
        // 'on' および未知値 (防御) は「画像を付ける」を返す。
        return true;
    }

    /**
     * Imagen のモデル ID を解決する。
     *
     * 1. キャッシュ済みモデル一覧を読む
     * 2. キャッシュに saved_model があればそのまま返す
     * 3. 無ければ API から再取得 (cache を上書き) して再検査
     * 4. それでも無ければ IMAGEN_FALLBACK_PRIORITY を上から走査
     * 5. それでも無ければ API が返した imagen-* を ID 降順で並べた 1 位
     * 6. 候補 0 件なら WP_Error
     *
     * 保存値 ($settings['imagen_model']) は書き換えない(API の一時的な揺らぎで保存値を破壊しないため)。
     *
     * @param string $api_key      Gemini API key
     * @param string $saved_model  ユーザー保存値
     * @return string|WP_Error     利用可能なモデル ID
     */
    public static function resolve_imagen_model( $api_key, $saved_model ) {
        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'Gemini API key is required for Imagen.', 'ai-auto-poster' ) );
        }

        $cached = class_exists( 'AIAP_AI_Client' ) ? AIAP_AI_Client::get_models_for( 'imagen' ) : array();

        // ステップ 2: キャッシュにヒット
        if ( $saved_model !== '' && is_array( $cached ) && isset( $cached[ $saved_model ] ) ) {
            return $saved_model;
        }

        // ステップ 3: API から再取得して cache を更新
        $fresh = class_exists( 'AIAP_AI_Client' ) ? AIAP_AI_Client::fetch_models_from_api( 'imagen' ) : array();
        if ( is_wp_error( $fresh ) ) {
            return $fresh;
        }
        if ( ! is_array( $fresh ) || empty( $fresh ) ) {
            return new WP_Error( 'imagen_no_models', __( 'No Imagen models are available.', 'ai-auto-poster' ) );
        }
        if ( class_exists( 'AIAP_AI_Client' ) ) {
            AIAP_AI_Client::cache_models( 'imagen', $fresh );
        }

        // ステップ 4: 再取得結果に saved_model
        if ( $saved_model !== '' && isset( $fresh[ $saved_model ] ) ) {
            return $saved_model;
        }

        // ステップ 5: 優先順位リスト走査
        foreach ( self::IMAGEN_FALLBACK_PRIORITY as $candidate ) {
            if ( isset( $fresh[ $candidate ] ) ) {
                return $candidate;
            }
        }

        // ステップ 6: imagen-* ID の降順 1 位
        $imagen_ids = array_values( array_filter( array_keys( $fresh ), function ( $id ) {
            return strpos( $id, 'imagen-' ) === 0;
        } ) );
        if ( ! empty( $imagen_ids ) ) {
            rsort( $imagen_ids );
            return $imagen_ids[0];
        }

        return new WP_Error( 'imagen_no_models', __( 'No Imagen models are available.', 'ai-auto-poster' ) );
    }

    /**
     * APIキーフィールド一覧
     */
    private static function get_key_fields() {
        return array( 'image_openai_api_key', 'image_gemini_api_key', 'stability_api_key', 'flux_api_key', 'unsplash_access_key', 'pixabay_api_key', 'pexels_api_key' );
    }

    const DEFAULT_IMAGE_SOURCE = 'dalle3';

    public static function get_settings() {
        $defaults = array(
            'image_enabled'        => 1,
            'image_source'         => self::DEFAULT_IMAGE_SOURCE,
            // 共通オプション
            'image_size'           => '1024x1024',
            'aspect_ratio'         => '1:1',
            'image_format'         => '16:9',
            'image_quality'        => 'low',
            // Bug fix (audit r9 self-audit): image_preset を defaults に追加。
            // UI hidden input (aiap-image-preset) で送信され handle_save_image_settings で
            // 保存される値だが、旧 defaults に欠落していたため初回ロード時に undefined と
            // なるリスクがあった。表示時は format/quality/max_width/compress から計算で
            // 再判定されるため値は事実上 UI ヒントだが、整合性のため defaults に置く。
            'image_preset'         => 'custom',
            // Bug fix (audit r9 self-audit): 旧 defaults にあった 'image_style' (グローバル) は
            // コード中で一度も参照されない dead key だった。プロバイダ別 dalle3_style /
            // imagen_style / stability_style / flux_style が実態として使われている。
            // 紛らわしいので defaults から削除した。
            // プロバイダごとのスタイル
            'dalle3_style'         => 'natural',
            'imagen_style'         => 'none',
            'stability_style'      => 'none',
            'flux_style'           => 'none',
            // プロバイダごとのモデル
            'dalle3_model'         => 'dall-e-3',
            'imagen_model'         => 'imagen-4.0-generate-001',
            // 画像後処理: 値はここで保持。トリガは SEO 設定の seo_img_webp に統合。
            'image_max_width'      => 1200,
            'image_compress'       => 85,
            // OpenAI image
            'image_openai_api_key' => '',
            // Google Imagen
            'image_gemini_api_key' => '',
            // Stability AI
            'stability_api_key'    => '',
            'stability_model'      => 'sd3.5-medium',
            // Flux (via Fal.ai)
            'flux_api_key'         => '',
            'flux_model'           => 'flux-2-pro',
            // フリー素材
            'unsplash_access_key'  => '',
            'pixabay_api_key'      => '',
            'pexels_api_key'       => '',
        );
        $saved = get_option( self::OPTION_KEY, array() );

        // 既存データのマイグレーション: image_source='none' → image_enabled=0 + デフォルトプロバイダ
        if ( isset( $saved['image_source'] ) && ! isset( $saved['image_enabled'] ) ) {
            if ( $saved['image_source'] === 'none' ) {
                $saved['image_enabled'] = 0;
                $saved['image_source']  = self::DEFAULT_IMAGE_SOURCE;
            } else {
                $saved['image_enabled'] = 1;
            }
        }

        // マイグレーション: 旧 image_webp=1 / image_optimize=1 → SEO の seo_img_webp に統合
        // (画像後処理のトリガを SEO セクション一本化したため、既存設定を seo_img_webp=1 に移送する)
        // 一度きりの処理として、レガシーキーが存在する場合のみ実行 → 削除して以降は走らない。
        $legacy_webp     = ! empty( $saved['image_webp'] );
        $legacy_optimize = ! empty( $saved['image_optimize'] );
        $has_legacy_keys = isset( $saved['image_webp'] ) || isset( $saved['image_optimize'] );

        if ( $has_legacy_keys ) {
            if ( $legacy_webp || $legacy_optimize ) {
                $ai_settings = get_option( AIAP_AI_Client::OPTION_KEY, array() );
                if ( ! is_array( $ai_settings ) ) {
                    $ai_settings = array();
                }
                $ai_settings['seo_enabled']  = 1;
                $ai_settings['seo_img_webp'] = 1;
                update_option( AIAP_AI_Client::OPTION_KEY, $ai_settings, false );
            }
            // レガシーキーを永久削除して以降のマイグレーションを抑止
            unset( $saved['image_webp'], $saved['image_optimize'] );
            update_option( self::OPTION_KEY, $saved, false );
        }

        $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): AI クライアント側と同じ問題。AUTH_KEY 欠落環境では get_settings →
        // decrypt_keys が API キー fields を空文字に置換するため、画像設定の他項目を変更しただけで
        // update_settings が呼ばれると画像系の API キーが空で上書きされてしまう。Crypto が利用
        // できない場合は 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 ] ) ) {
                        $settings[ $f ] = $existing[ $f ];
                    }
                }
            }
        }
        // autoload=false: 暗号化済みAPIキーを含む大きめのオプション。
        // 管理画面と画像生成パスでしか参照されないため、フロント全リクエストでの自動ロードは不要。
        $settings = AIAP_Crypto::encrypt_keys( $settings, self::get_key_fields() );
        update_option( self::OPTION_KEY, $settings, false );
    }

    /**
     * 利用可能な画像ソース一覧
     */
    /**
     * 指定したソースで画像生成に必要な設定（APIキー等）が揃っているか確認
     *
     * @param string $source
     * @return bool
     */
    /**
     * 指定ソースに必要な API キーが設定されているか判定する。
     *
     * 第2引数 $settings に画像設定配列を渡すと、その配列を直接見る(DB を読まない)。
     * 省略時は `get_settings()` でDBからロード。保存処理中に「これから保存しようと
     * している値で判定したい」用途のため第2引数を提供している。
     *
     * @param string     $source 画像プロバイダキー
     * @param array|null $settings 画像設定配列(省略時は DB から取得)
     * @return bool
     */
    public static function has_required_credentials( $source, $settings = null ) {
        if ( $source === 'openverse' ) {
            return true; // APIキー不要
        }
        $im = is_array( $settings ) ? $settings : self::get_settings();
        switch ( $source ) {
            case 'dalle3':    return ! empty( $im['image_openai_api_key'] );
            case 'imagen':    return ! empty( $im['image_gemini_api_key'] );
            case 'stability': return ! empty( $im['stability_api_key'] );
            case 'flux':      return ! empty( $im['flux_api_key'] );
            case 'unsplash':  return ! empty( $im['unsplash_access_key'] );
            case 'pixabay':   return ! empty( $im['pixabay_api_key'] );
            case 'pexels':    return ! empty( $im['pexels_api_key'] );
        }
        return false;
    }

    public static function get_sources() {
        return array(
            'dalle3'    => 'DALL-E 3 (OpenAI)',
            'imagen'    => 'Imagen (Google)',
            'stability' => 'Stability AI',
            'flux'      => 'Flux (Black Forest Labs)',
            'unsplash'  => 'Unsplash',
            'pixabay'   => 'Pixabay',
            'pexels'    => 'Pexels',
            'openverse' => 'Openverse',
        );
    }

    private static function normalize_generation_settings( $settings ) {
        $format = isset( $settings['image_format'] ) ? $settings['image_format'] : '16:9';
        // Bug fix (audit r7): 旧テーブルでは '4:3' の DALL-E size が 1024x1024 (正方形) となり、
        // ユーザーが「横長 4:3」を選んだ意図と矛盾していた。横長系として扱うため、近似横長
        // である 1792x1024 を割り当てる (GPT-Image-1 ではこの値はサポート外なので、
        // call_dalle3 内の pick_dalle_size_by_format で 1536x1024 に丸められる)。
        $sizes  = array(
            '16:9' => array( 'size' => '1792x1024', 'flux' => '1344x768',  'aspect' => '16:9', 'stock' => 'landscape' ),
            '1:1'  => array( 'size' => '1024x1024', 'flux' => '1024x1024', 'aspect' => '1:1',  'stock' => 'squarish' ),
            '9:16' => array( 'size' => '1024x1792', 'flux' => '768x1344',  'aspect' => '9:16', 'stock' => 'portrait' ),
            '4:3'  => array( 'size' => '1792x1024', 'flux' => '1024x768',  'aspect' => '4:3',  'stock' => 'landscape' ),
        );
        $map = isset( $sizes[ $format ] ) ? $sizes[ $format ] : $sizes['16:9'];

        $settings['image_size']        = $map['size'];
        $settings['flux_image_size']   = $map['flux'];
        $settings['aspect_ratio']      = $map['aspect'];
        $settings['stock_orientation'] = $map['stock'];

        $quality = isset( $settings['image_quality'] ) ? $settings['image_quality'] : 'low';
        if ( $quality === 'high' ) {
            $settings['dalle_quality'] = 'hd';
            $settings['flux_steps']    = 40;
        } elseif ( $quality === 'medium' ) {
            $settings['dalle_quality'] = 'standard';
            $settings['flux_steps']    = 25;
        } else {
            $settings['dalle_quality'] = 'standard';
            $settings['flux_steps']    = 15;
        }

        return $settings;
    }

    /**
     * 投稿者が選んだ image_format から「向き」(landscape/square/portrait) を返す。
     *
     * Bug fix (audit r7): モデルが希望サイズをサポートしていない場合の代替選定で使う。
     *
     * @param string $format '16:9'|'4:3'|'1:1'|'9:16'
     * @return string 'landscape'|'square'|'portrait'
     */
    private static function format_to_orientation( $format ) {
        switch ( $format ) {
            case '1:1':  return 'square';
            case '9:16': return 'portrait';
            case '16:9':
            case '4:3':
            default:     return 'landscape';
        }
    }

    /**
     * DALL-E / GPT-Image 用に、ユーザー選択の format に最も近いサポート済み size を返す。
     *
     * Bug fix (audit r7): 旧コードはユーザー指定 image_size がモデルのサポート外なら無条件で
     * sizes[0] にフォールバックしていた。GPT-Image-1 の sizes[0] は '1024x1024' (正方形) なので、
     * ユーザーが 16:9 を選んでも常に正方形画像が生成される問題があった。同じ「向き」のサイズが
     * モデルにあればそれを返し、無ければ正方形 → 何でも、の順で代替する。
     *
     * @param array  $caps   get_model_capabilities の戻り値
     * @param string $format 投稿者が選んだ format
     * @return string         'WxH' 形式の size 文字列
     */
    private static function pick_dalle_size_by_format( $caps, $format ) {
        if ( empty( $caps['sizes'] ) ) {
            return '1024x1024';
        }
        $orientation = self::format_to_orientation( $format );

        $by_orientation = array( 'landscape' => array(), 'square' => array(), 'portrait' => array() );
        foreach ( $caps['sizes'] as $sz ) {
            if ( $sz === 'auto' ) continue;
            if ( ! preg_match( '/^(\d+)x(\d+)$/', $sz, $m ) ) continue;
            $w = (int) $m[1]; $h = (int) $m[2];
            if ( $w > $h )       $by_orientation['landscape'][] = $sz;
            elseif ( $w < $h )   $by_orientation['portrait'][]  = $sz;
            else                 $by_orientation['square'][]    = $sz;
        }

        if ( ! empty( $by_orientation[ $orientation ] ) ) {
            return $by_orientation[ $orientation ][0];
        }
        if ( ! empty( $by_orientation['square'] ) ) {
            return $by_orientation['square'][0];
        }
        // どれにも該当しない (auto しか無い等) のときは API 既定に任せる
        foreach ( $caps['sizes'] as $sz ) {
            if ( $sz !== 'auto' ) return $sz;
        }
        return '1024x1024';
    }

    /**
     * スタイル選択肢
     */
    public static function get_style_options() {
        return array(
            'auto', 'photo', 'natural', 'vivid', 'illustration', 'digital_art',
            'watercolor', 'oil_painting', '3d_render', 'anime',
            'flat_design', 'minimalist', 'cinematic', 'isometric',
        );
    }

    /**
     * プロバイダ×モデルごとの機能サポートマトリクス
     *
     * 各モデルがAPIで受け付けるパラメータを一元管理する。スタイル欄の表示/非表示、
     * size/quality の範囲、response_format のサポート可否などをここで判定する。
     *
     * @param string $provider プロバイダキー (dalle3, stability, imagen, flux)
     * @param string $model    モデルID (空なら設定から取得)
     * @return array {
     *     @type array  $styles          スタイル選択肢。空配列ならスタイル非対応モデル。
     *     @type array  $sizes           受け付ける size 値
     *     @type array  $quality_values  受け付ける quality 値
     *     @type bool   $response_format response_format パラメータを指定可能か
     *     @type string $family          API系統の識別子 (dall-e / gpt-image / その他)
     * }
     */
    public static function get_model_capabilities( $provider, $model = '' ) {
        if ( $model === '' ) {
            $settings = self::get_settings();
            $model    = $settings[ $provider . '_model' ] ?? '';
        }

        if ( $provider === 'dalle3' ) {
            // GPT Image系: style / response_format を受け付けない、size・qualityが別レンジ
            if ( strpos( $model, 'gpt-image' ) === 0 ) {
                return array(
                    'styles'          => array(),
                    'sizes'           => array( '1024x1024', '1536x1024', '1024x1536', 'auto' ),
                    'quality_values'  => array( 'low', 'medium', 'high', 'auto' ),
                    'response_format' => false,
                    'family'          => 'gpt-image',
                );
            }
            // dall-e-3 (既定): style (natural/vivid) / response_format サポート
            return array(
                'styles'          => array(
                    'natural' => array( 'icon' => '🌿', 'label_key' => 'imgstyle_natural' ),
                    'vivid'   => array( 'icon' => '🎨', 'label_key' => 'imgstyle_vivid' ),
                ),
                'sizes'           => array( '1024x1024', '1792x1024', '1024x1792' ),
                'quality_values'  => array( 'standard', 'hd' ),
                'response_format' => true,
                'family'          => 'dall-e-3',
            );
        }

        if ( $provider === 'stability' ) {
            return array(
                'styles'          => self::get_stability_style_list(),
                'sizes'           => array(),
                'quality_values'  => array(),
                'response_format' => false,
                'family'          => 'stability',
            );
        }

        if ( $provider === 'imagen' || $provider === 'flux' ) {
            return array(
                'styles'          => self::get_generic_style_list(),
                'sizes'           => array(),
                'quality_values'  => array(),
                'response_format' => false,
                'family'          => $provider,
            );
        }

        return array(
            'styles'          => array(),
            'sizes'           => array(),
            'quality_values'  => array(),
            'response_format' => false,
            'family'          => $provider,
        );
    }

    /**
     * Stability AI のスタイル定義(内部利用)
     */
    private static function get_stability_style_list() {
        return array(
            'none'             => array( 'icon' => '⚪', 'label_key' => 'imgstyle_none' ),
            '3d-model'         => array( 'icon' => '🧊', 'label_key' => 'imgstyle_3d-model' ),
            'analog-film'      => array( 'icon' => '🎞', 'label_key' => 'imgstyle_analog-film' ),
            'anime'            => array( 'icon' => '🎌', 'label_key' => 'imgstyle_anime' ),
            'cinematic'        => array( 'icon' => '🎬', 'label_key' => 'imgstyle_cinematic' ),
            'comic-book'       => array( 'icon' => '💥', 'label_key' => 'imgstyle_comic-book' ),
            'digital-art'      => array( 'icon' => '💻', 'label_key' => 'imgstyle_digital-art' ),
            'enhance'          => array( 'icon' => '✨', 'label_key' => 'imgstyle_enhance' ),
            'fantasy-art'      => array( 'icon' => '🐉', 'label_key' => 'imgstyle_fantasy-art' ),
            'isometric'        => array( 'icon' => '📦', 'label_key' => 'imgstyle_isometric' ),
            'line-art'         => array( 'icon' => '✏️', 'label_key' => 'imgstyle_line-art' ),
            'low-poly'         => array( 'icon' => '🔷', 'label_key' => 'imgstyle_low-poly' ),
            'modeling-compound'=> array( 'icon' => '🧱', 'label_key' => 'imgstyle_modeling-compound' ),
            'neon-punk'        => array( 'icon' => '💫', 'label_key' => 'imgstyle_neon-punk' ),
            'origami'          => array( 'icon' => '🦢', 'label_key' => 'imgstyle_origami' ),
            'photographic'     => array( 'icon' => '📷', 'label_key' => 'imgstyle_photographic' ),
            'pixel-art'        => array( 'icon' => '👾', 'label_key' => 'imgstyle_pixel-art' ),
            'tile-texture'     => array( 'icon' => '🧩', 'label_key' => 'imgstyle_tile-texture' ),
        );
    }

    /**
     * Imagen / Flux 共通の汎用スタイル定義(内部利用)
     */
    private static function get_generic_style_list() {
        return array(
            'none'         => array( 'icon' => '⚪', 'label_key' => 'imgstyle_none' ),
            'realistic'    => array( 'icon' => '📷', 'label_key' => 'imgstyle_realistic' ),
            'cartoon'      => array( 'icon' => '🖍', 'label_key' => 'imgstyle_cartoon' ),
            'anime'        => array( 'icon' => '🎌', 'label_key' => 'imgstyle_anime' ),
            '3d-render'    => array( 'icon' => '🧊', 'label_key' => 'imgstyle_3d-render' ),
            'watercolor'   => array( 'icon' => '🎨', 'label_key' => 'imgstyle_watercolor' ),
            'oil-painting' => array( 'icon' => '🖼', 'label_key' => 'imgstyle_oil-painting' ),
            'flat-design'  => array( 'icon' => '📐', 'label_key' => 'imgstyle_flat-design' ),
            'pixel-art'    => array( 'icon' => '👾', 'label_key' => 'imgstyle_pixel-art' ),
        );
    }

    /**
     * プロバイダごとのスタイル選択肢定義
     *
     * モデルIDを渡すと、そのモデルがサポートするスタイルのみを返す。
     * モデル未指定時は後方互換として設定DBから現在のモデルを引く。
     *
     * @param string $provider プロバイダキー
     * @param string $model    モデルID (省略可)
     * @return array
     */
    public static function get_provider_styles( $provider, $model = '' ) {
        $caps = self::get_model_capabilities( $provider, $model );
        return $caps['styles'];
    }

    /**
     * (旧APIの互換用) プロバイダ直指定時のスタイル一覧を返す。
     * 新規コードでは get_provider_styles() を使うこと。
     * 既存の保存値検証など、プロバイダの全サポートスタイルを列挙したい時に使用。
     */
    public static function get_provider_all_styles( $provider ) {
        switch ( $provider ) {
            case 'dalle3':
                return array(
                    'natural' => array( 'icon' => '🌿', 'label_key' => 'imgstyle_natural' ),
                    'vivid'   => array( 'icon' => '🎨', 'label_key' => 'imgstyle_vivid' ),
                );

            case 'stability':
                return self::get_stability_style_list();

            case 'imagen':
            case 'flux':
                return self::get_generic_style_list();
        }
        return array();
    }

    /**
     * 汎用スタイルキーをプロンプト用の英語フレーズに変換
     */
    public static function style_to_prompt_text( $style ) {
        $map = array(
            'realistic'    => 'photorealistic photography style',
            'cartoon'      => 'hand-drawn cartoon illustration style',
            'anime'        => 'anime/manga style',
            '3d-render'    => '3D rendered style',
            'watercolor'   => 'watercolor painting style',
            'oil-painting' => 'oil painting style',
            'flat-design'  => 'flat design, simple shapes',
            'pixel-art'    => 'pixel art style',
        );
        return isset( $map[ $style ] ) ? $map[ $style ] : '';
    }

    /**
     * 記事タイトルからアイキャッチ画像を生成/取得し、投稿に設定する
     *
     * @param int    $post_id    投稿ID
     * @param string $title      記事タイトル(キーワード/プロンプト生成用)
     * @return int|WP_Error      アタッチメントID or エラー
     */
    public static function generate_and_attach( $post_id, $title, $slug = '', $preset_image_prompt = '', $user_prompt = '', $article_content = '', $override_enabled = null, $run_source = 'src_auto' ) {
        $settings = self::get_settings();

        // アイキャッチ生成が無効ならスキップ (エラーではない)
        // 戻り値 false は「設定により無効化されている」ことを示す。
        // 呼び出し側では is_wp_error() のみを失敗扱いとし、false は無視する。
        //
        // $override_enabled が明示指定されていればそちらを優先(タスク/手動側の per-post 設定)。
        // null の場合のみグローバル設定にフォールバック。
        $effective_enabled = is_null( $override_enabled )
            ? ! empty( $settings['image_enabled'] )
            : (bool) $override_enabled;
        if ( ! $effective_enabled ) {
            return false;
        }

        $settings = self::normalize_generation_settings( $settings );
        $source   = $settings['image_source'];

        // image_source が 'none' の場合もスキップ扱い (image_enabled=1 でも source=none なら何もしない)
        if ( $source === 'none' ) {
            return false;
        }

        $is_ai = in_array( $source, array( 'dalle3', 'imagen', 'stability', 'flux' ), true );

        // AIが記事生成時に書いた image_prompt を最優先で使う(AI画像生成時のみ)
        if ( $is_ai && $preset_image_prompt !== '' ) {
            $query = $preset_image_prompt;
        } else {
            $query = self::generate_image_query( $title, $is_ai, $settings, $source, $user_prompt, $article_content );
        }

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

        // リトライ可能な API エラー(レート制限・サーバ一時障害)に備えて call_with_retry でラップ。
        // 永続エラー(課金枯渇/認証/入力不正)は WP_Error の retryable=false で即中断される。
        // テキスト AI 側の generate_with_retry と対称な挙動。
        $log_source = (string) $run_source;
        $on_retry = function ( $attempt, $err ) use ( $log_source ) {
            // Bug fix (audit r9 self-audit, #36/#37): レベルを 'warning' に統一し、
            // 分母を MAX_RETRY+1 (=4) にする (3 経路で食い違っていた retry log を統一)。
            AIAP_Logger::add( 'warning', 'log_image_retry', array(
                $log_source,
                $attempt,
                self::IMAGE_GENERATE_MAX_RETRY + 1,
                AIAP_AI_Client::format_error_for_log( $err ),
            ) );
        };

        switch ( $source ) {
            case 'dalle3':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_dalle3( $query, $settings );
                }, $on_retry );
                break;
            case 'imagen':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_imagen( $query, $settings );
                }, $on_retry );
                break;
            case 'stability':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_stability( $query, $settings );
                }, $on_retry );
                break;
            case 'flux':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_flux( $query, $settings );
                }, $on_retry );
                break;
            case 'unsplash':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_unsplash( $query, $settings );
                }, $on_retry );
                break;
            case 'pixabay':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_pixabay( $query, $settings );
                }, $on_retry );
                break;
            case 'pexels':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_pexels( $query, $settings );
                }, $on_retry );
                break;
            case 'openverse':
                $image_result = self::call_with_retry( function () use ( $query, $settings ) {
                    return self::call_openverse( $query, $settings );
                }, $on_retry );
                break;
            default:
                /* translators: %s = image source key (e.g. dalle3) */
                return new WP_Error( 'invalid_source', sprintf( __( 'Invalid image source: %s', 'ai-auto-poster' ), $source ) );
        }

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

        return self::attach_image_to_post( $post_id, $image_result, $title, $slug );
    }

    /**
     * テキストAIを使って画像用プロンプト/キーワードを生成
     *
     * @param string $title 記事タイトル
     * @param bool   $is_ai AI画像生成向けか(true)、フリー素材検索向けか(false)
     * @return string|WP_Error
     */
    private static function generate_image_query( $title, $is_ai, $img_settings = array(), $source = '', $user_prompt = '', $article_content = '' ) {
        // 画像AIプロバイダが OpenAI 以外でも、画像プロンプト作成のために
        // テキストAI(OpenAI等)を呼ぶと、設定していないAIのエラーが表示される。
        // 画像側は画像AI設定だけで完結させるため、ここでは外部テキストAIを呼ばない。
        if ( $is_ai ) {
            return self::build_local_ai_image_prompt( $title, $img_settings, $source, $user_prompt, $article_content );
        }

        // Pexels / Unsplash / Pixabay / Openverse などの素材検索でも、
        // 検索キーワード作成のためにテキストAIは呼ばない。
        return self::build_local_stock_search_query( $title, $user_prompt, $article_content );
    }

    /**
     * AI画像生成用プロンプトをローカルで作成する。
     * 画像AI実行時に、別設定のテキストAIを呼ばないための処理。
     */
    private static function build_local_ai_image_prompt( $title, $img_settings = array(), $source = '', $user_prompt = '', $article_content = '' ) {
        $parts = array();
        $parts[] = 'Create a blog featured image with no text, no letters, no logo, and no watermark.';

        $plain_title = trim( wp_strip_all_tags( (string) $title ) );
        if ( $plain_title !== '' ) {
            $parts[] = 'Article title: ' . $plain_title . '.';
        }

        $plain_article = trim( wp_strip_all_tags( (string) $article_content ) );
        if ( $plain_article !== '' ) {
            $parts[] = 'Reflect this article atmosphere and concrete objects: ' . mb_substr( $plain_article, 0, 900 ) . '.';
        }

        $plain_user_prompt = trim( wp_strip_all_tags( (string) $user_prompt ) );
        if ( $plain_user_prompt !== '' ) {
            $parts[] = 'Additional direction: ' . mb_substr( $plain_user_prompt, 0, 600 ) . '.';
        }

        $style_text = '';
        if ( $source !== '' ) {
            $style_key = $source . '_style';
            $style_val = $img_settings[ $style_key ] ?? '';

            if ( $source === 'dalle3' ) {
                if ( $style_val === 'vivid' ) {
                    $style_text = 'vivid, colorful style';
                } elseif ( $style_val === 'natural' ) {
                    $style_text = 'natural, organic style';
                }
            } elseif ( $source === 'stability' ) {
                $phrase_map = array(
                    '3d-model'          => '3D model render',
                    'analog-film'       => 'analog film photography',
                    'anime'             => 'anime or manga style',
                    'cinematic'         => 'cinematic style',
                    'comic-book'        => 'comic book illustration',
                    'digital-art'       => 'digital art',
                    'enhance'           => 'enhanced detail',
                    'fantasy-art'       => 'fantasy art',
                    'isometric'         => 'isometric view style',
                    'line-art'          => 'line art',
                    'low-poly'          => 'low-poly 3D style',
                    'origami'           => 'origami folded paper style',
                    'photographic'      => 'photorealistic photography',
                    'pixel-art'         => 'pixel art style',
                    'tile-texture'      => 'seamless tile texture',
                );
                if ( isset( $phrase_map[ $style_val ] ) ) {
                    $style_text = $phrase_map[ $style_val ];
                }
            } else {
                $style_text = self::style_to_prompt_text( $style_val );
            }
        }

        if ( $style_text !== '' ) {
            $parts[] = 'Visual style: ' . $style_text . '.';
        }

        $parts[] = 'Use a clean composition suitable for a WordPress featured image.';

        return trim( implode( ' ', array_filter( $parts ) ) );
    }

    /**
     * フリー素材検索用のキーワードをローカルで生成する。
     * OpenAI/Claude/Gemini等のテキストAIは呼ばない。
     */
    private static function build_local_stock_search_query( $title, $user_prompt = '', $article_content = '' ) {
        $source_text = trim( wp_strip_all_tags( $title . ' ' . $article_content . ' ' . $user_prompt ) );

        $map = array(
            '自然' => 'nature', '森' => 'forest', '林' => 'forest', '木' => 'trees',
            '葉' => 'leaves', '花' => 'flowers', '桜' => 'cherry blossoms',
            '春' => 'spring', '夏' => 'summer', '秋' => 'autumn', '冬' => 'winter',
            '空' => 'sky', '雲' => 'clouds', '雨' => 'rain', '雪' => 'snow',
            '海' => 'sea', '川' => 'river', '山' => 'mountain',
            '朝' => 'morning', '夕' => 'sunset', '夜' => 'night',
            '文房具' => 'stationery', 'ノート' => 'notebook', '手帳' => 'notebook',
            '万年筆' => 'fountain pen', 'ペン' => 'pen', '鉛筆' => 'pencil',
            'タイプライター' => 'typewriter', 'パソコン' => 'laptop', 'PC' => 'laptop',
            'コーヒー' => 'coffee', '珈琲' => 'coffee', 'カップ' => 'coffee cup',
            '机' => 'desk', '暮らし' => 'lifestyle', '生活' => 'lifestyle',
            '散歩' => 'walking', '公園' => 'park', '庭' => 'garden',
        );

        $keywords = array();
        foreach ( $map as $jp => $en ) {
            if ( $source_text !== '' && mb_stripos( $source_text, $jp ) !== false ) {
                $keywords[] = $en;
            }
        }

        if ( preg_match_all( '/[A-Za-z][A-Za-z0-9\\-]{2,}/', $source_text, $matches ) ) {
            foreach ( $matches[0] as $word ) {
                $word = strtolower( $word );
                if ( in_array( $word, array( 'the', 'and', 'for', 'with', 'this', 'that', 'from' ), true ) ) {
                    continue;
                }
                $keywords[] = $word;
            }
        }

        $keywords = array_values( array_unique( array_filter( $keywords ) ) );

        if ( empty( $keywords ) ) {
            return 'nature lifestyle';
        }

        return implode( ' ', array_slice( $keywords, 0, 5 ) );
    }

    // ========== AI画像生成 ==========

    /**
     * DALL-E 3 (OpenAI)
     * APIキーは画像AIプロバイダ設定を使用する
     */
    private static function call_dalle3( $prompt, $settings ) {
        $api_key = $settings['image_openai_api_key'] ?? '';

        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'OpenAI API key is required for OpenAI image generation.', 'ai-auto-poster' ) );
        }

        $model = ! empty( $settings['dalle3_model'] ) ? $settings['dalle3_model'] : 'dall-e-3';
        $caps  = self::get_model_capabilities( 'dalle3', $model );
        $body  = array(
            'model'  => $model,
            'prompt' => $prompt,
            'n'      => 1,
        );

        // size: モデルごとにサポート範囲が違う
        // Bug fix (audit r7): 旧コードは未サポート時に sizes[0] にフォールバックしていた。
        // gpt-image-1 では sizes[0] = '1024x1024' (正方形) なので、ユーザーが 16:9 を選んでも
        // 強制的に正方形になっていた。pick_dalle_size_by_format で「同じ向き」の代替を選ぶ。
        $requested_size = isset( $settings['image_size'] ) ? $settings['image_size'] : '';
        $format         = isset( $settings['image_format'] ) ? $settings['image_format'] : '16:9';
        if ( ! empty( $caps['sizes'] ) && in_array( $requested_size, $caps['sizes'], true ) ) {
            $body['size'] = $requested_size;
        } else {
            $body['size'] = self::pick_dalle_size_by_format( $caps, $format );
        }

        // quality: モデルごとに許容値が違う
        if ( $caps['family'] === 'gpt-image' ) {
            $requested_quality = isset( $settings['dalle_quality'] ) ? $settings['dalle_quality'] : 'medium';
            $body['quality']   = in_array( $requested_quality, $caps['quality_values'], true ) ? $requested_quality : 'medium';
        } else {
            $requested_quality = isset( $settings['dalle_quality'] ) ? $settings['dalle_quality'] : 'standard';
            $body['quality']   = in_array( $requested_quality, $caps['quality_values'], true ) ? $requested_quality : 'standard';
        }

        // style: dall-e-3 のみサポート。GPT-Image系には絶対に送らない
        if ( ! empty( $caps['styles'] ) ) {
            $style_val = isset( $settings['dalle3_style'] ) ? $settings['dalle3_style'] : '';
            if ( isset( $caps['styles'][ $style_val ] ) ) {
                $body['style'] = $style_val;
            } else {
                // 保存値がサポート外なら既定のnaturalに
                $body['style'] = 'natural';
            }
        }

        // response_format: dall-e-3 のみ指定可能 (GPT-Image系は常にbase64)
        if ( ! empty( $caps['response_format'] ) ) {
            $body['response_format'] = 'url';
        }

        $response = wp_remote_post( 'https://api.openai.com/v1/images/generations', array(
            'timeout' => 120,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $api_key,
            ),
            'body' => wp_json_encode( $body ),
        ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'dalle3_error', __( 'OpenAI image error:', 'ai-auto-poster' ), $response );
        }

        // レスポンス形式: dall-e-3 は url を返す、GPT-Image系は b64_json のみ
        if ( ! empty( $data['data'][0]['url'] ) ) {
            return array(
                'url'       => $data['data'][0]['url'],
                'source'    => 'dalle3',
                'extension' => 'png',
            );
        }
        if ( ! empty( $data['data'][0]['b64_json'] ) ) {
            return array(
                'base64'    => $data['data'][0]['b64_json'],
                'source'    => 'dalle3',
                'extension' => 'png',
            );
        }

        return new WP_Error( 'dalle3_no_data', __( 'OpenAI image API did not return image data.', 'ai-auto-poster' ) );
    }

    /**
     * Imagen (Google Gemini)
     * APIキーは画像AIプロバイダ設定を使用する
     */
    private static function call_imagen( $prompt, $settings ) {
        $api_key = $settings['image_gemini_api_key'] ?? '';

        if ( empty( $api_key ) ) {
            return new WP_Error( 'no_api_key', __( 'Gemini API key is required for Imagen.', 'ai-auto-poster' ) );
        }

        $saved_model = ! empty( $settings['imagen_model'] ) ? $settings['imagen_model'] : self::IMAGEN_FALLBACK_PRIORITY[0];
        $resolved    = self::resolve_imagen_model( $api_key, $saved_model );
        if ( is_wp_error( $resolved ) ) {
            return $resolved;
        }
        $model = $resolved;
        // 保存値と異なる場合のみ「フォールバック発動」をログに残す。保存値は書き換えない。
        if ( $model !== $saved_model && class_exists( 'AIAP_Logger' ) ) {
            AIAP_Logger::add( 'info', 'log_imagen_fallback', array( $saved_model, $model ) );
        }
        $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model )
             . ':predict?key=' . rawurlencode( $api_key );

        $aspect = isset( $settings['aspect_ratio'] ) ? $settings['aspect_ratio'] : '1:1';
        // Imagen 3 が受け付ける aspectRatio: "1:1", "3:4", "4:3", "9:16", "16:9"
        // プラグインの 4:3 もそのまま受け付ける
        $allowed_aspect = array( '1:1', '3:4', '4:3', '9:16', '16:9' );
        if ( ! in_array( $aspect, $allowed_aspect, true ) ) {
            $aspect = '1:1';
        }

        $response = wp_remote_post( $url, array(
            'timeout' => 120,
            'headers' => array( 'Content-Type' => 'application/json' ),
            'body'    => wp_json_encode( array(
                'instances' => array(
                    array( 'prompt' => $prompt ),
                ),
                'parameters' => array(
                    'sampleCount' => 1,
                    'aspectRatio' => $aspect,
                ),
            ) ),
        ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'imagen_error', __( 'Imagen error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['predictions'][0]['bytesBase64Encoded'] ) ) {
            return new WP_Error( 'imagen_no_data', __( 'Imagen did not return image data.', 'ai-auto-poster' ) );
        }

        return array(
            'base64'    => $data['predictions'][0]['bytesBase64Encoded'],
            'source'    => 'imagen',
            'extension' => 'png',
        );
    }

    /**
     * Stability AI
     */
    private static function call_stability( $prompt, $settings ) {
        if ( empty( $settings['stability_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Stability AI API key is not set.', 'ai-auto-poster' ) );
        }

        $model = ! empty( $settings['stability_model'] ) ? $settings['stability_model'] : 'sd3.5-medium';

        $url = 'https://api.stability.ai/v2beta/stable-image/generate/sd3';

        $boundary = wp_generate_password( 24, false );
        $body  = '';
        $body .= "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"prompt\"\r\n\r\n";
        $body .= $prompt . "\r\n";
        $body .= "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"model\"\r\n\r\n";
        $body .= $model . "\r\n";
        $body .= "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"aspect_ratio\"\r\n\r\n";
        // Bug fix (audit r7): Stability AI v2beta SD3 がサポートする aspect_ratio は
        // "21:9, 16:9, 3:2, 5:4, 1:1, 4:5, 2:3, 9:16, 9:21" のみ。プラグインの '4:3' は
        // 非対応のため、近似横長として '3:2' に置換する (5:4 より 3:2 の方が映える)。
        $stability_aspect = isset( $settings['aspect_ratio'] ) ? $settings['aspect_ratio'] : '1:1';
        $stability_supported = array( '21:9', '16:9', '3:2', '5:4', '1:1', '4:5', '2:3', '9:16', '9:21' );
        if ( ! in_array( $stability_aspect, $stability_supported, true ) ) {
            // '4:3' 等の代替: 一旦 '3:2' に丸める (横長近似)。それ以外の未知値は '1:1' に。
            $stability_aspect = ( $stability_aspect === '4:3' ) ? '3:2' : '1:1';
        }
        $body .= $stability_aspect . "\r\n";
        $body .= "--{$boundary}\r\n";
        $body .= "Content-Disposition: form-data; name=\"output_format\"\r\n\r\n";
        $body .= "png\r\n";
        $body .= "--{$boundary}--\r\n";

        $response = wp_remote_post( $url, array(
            'timeout' => 120,
            'headers' => array(
                'Authorization' => 'Bearer ' . $settings['stability_api_key'],
                'Content-Type'  => 'multipart/form-data; boundary=' . $boundary,
                'Accept'        => 'application/json',
            ),
            'body' => $body,
        ) );

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

        $code = wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }

        if ( $code !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'stability_error', __( 'Stability AI error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['image'] ) ) {
            return new WP_Error( 'stability_no_data', __( 'Stability AI did not return image data.', 'ai-auto-poster' ) );
        }

        return array(
            'base64'    => $data['image'],
            'source'    => 'stability',
            'extension' => 'png',
        );
    }

    // ========== フリー素材 ==========

    /**
     * Unsplash
     */
    private static function call_unsplash( $query, $settings ) {
        if ( empty( $settings['unsplash_access_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Unsplash access key is not set.', 'ai-auto-poster' ) );
        }

        $url = add_query_arg( array(
            'query'    => $query,
            'per_page' => 1,
            'orientation' => isset( $settings['stock_orientation'] ) ? $settings['stock_orientation'] : 'landscape',
            'client_id'   => $settings['unsplash_access_key'],
        ), 'https://api.unsplash.com/search/photos' );

        $response = wp_remote_get( $url, array( 'timeout' => 30 ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'unsplash_error', __( 'Unsplash error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['results'][0]['urls']['regular'] ) ) {
            /* translators: %s = search query */
            return new WP_Error( 'unsplash_no_result', sprintf( __( 'No image found on Unsplash: %s', 'ai-auto-poster' ), $query ) );
        }

        // Unsplash の guideline に従い「ダウンロード追跡」を呼ぶ。
        // download_location には既に ?ixid=... が付与されているため
        // 文字列結合ではなく add_query_arg で client_id を追加する。
        if ( ! empty( $data['results'][0]['links']['download_location'] ) ) {
            $track_url = add_query_arg(
                array( 'client_id' => $settings['unsplash_access_key'] ),
                $data['results'][0]['links']['download_location']
            );
            wp_remote_get( $track_url, array( 'timeout' => 5 ) );
        }

        return array(
            'url'       => $data['results'][0]['urls']['regular'],
            'source'    => 'unsplash',
            'extension' => 'jpg',
            'credit'    => isset( $data['results'][0]['user']['name'] ) ? $data['results'][0]['user']['name'] : '',
        );
    }

    /**
     * Pixabay
     */
    private static function call_pixabay( $query, $settings ) {
        if ( empty( $settings['pixabay_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Pixabay API key is not set.', 'ai-auto-poster' ) );
        }

        $url = add_query_arg( array(
            'key'       => $settings['pixabay_api_key'],
            'q'         => $query,
            'per_page'  => 3,
            'image_type' => 'photo',
            'orientation' => ( isset( $settings['stock_orientation'] ) && $settings['stock_orientation'] === 'portrait' ) ? 'vertical' : 'horizontal',
            'safesearch' => 'true',
        ), 'https://pixabay.com/api/' );

        $response = wp_remote_get( $url, array( 'timeout' => 30 ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'pixabay_error', __( 'Pixabay error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['hits'][0]['largeImageURL'] ) ) {
            /* translators: %s = search query */
            return new WP_Error( 'pixabay_no_result', sprintf( __( 'No image found on Pixabay: %s', 'ai-auto-poster' ), $query ) );
        }

        return array(
            'url'       => $data['hits'][0]['largeImageURL'],
            'source'    => 'pixabay',
            'extension' => 'jpg',
            'credit'    => isset( $data['hits'][0]['user'] ) ? $data['hits'][0]['user'] : '',
        );
    }

    /**
     * Pexels
     */
    private static function call_pexels( $query, $settings ) {
        if ( empty( $settings['pexels_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Pexels API key is not set.', 'ai-auto-poster' ) );
        }

        $url = add_query_arg( array(
            'query'    => $query,
            'per_page' => 1,
            'orientation' => ( isset( $settings['stock_orientation'] ) && $settings['stock_orientation'] === 'squarish' ) ? 'square' : ( $settings['stock_orientation'] ?? 'landscape' ),
        ), 'https://api.pexels.com/v1/search' );

        $response = wp_remote_get( $url, array(
            'timeout' => 30,
            'headers' => array(
                'Authorization' => $settings['pexels_api_key'],
            ),
        ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'pexels_error', __( 'Pexels error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['photos'][0]['src']['large'] ) ) {
            /* translators: %s = search query */
            return new WP_Error( 'pexels_no_result', sprintf( __( 'No image found on Pexels: %s', 'ai-auto-poster' ), $query ) );
        }

        return array(
            'url'       => $data['photos'][0]['src']['large'],
            'source'    => 'pexels',
            'extension' => 'jpg',
            'credit'    => isset( $data['photos'][0]['photographer'] ) ? $data['photos'][0]['photographer'] : '',
        );
    }


    // ========== Flux (Fal.ai) ==========

    /**
     * Flux via Fal.ai API
     */
    private static function call_flux( $prompt, $settings ) {
        if ( empty( $settings['flux_api_key'] ) ) {
            return new WP_Error( 'no_api_key', __( 'Fal.ai API key is not set', 'ai-auto-poster' ) );
        }

        $model = ! empty( $settings['flux_model'] ) ? $settings['flux_model'] : 'flux-2-pro';
        $size  = isset( $settings['flux_image_size'] ) ? $settings['flux_image_size'] : ( $settings['image_size'] ?? '1024x1024' );
        $parts = explode( 'x', $size );
        $width  = isset( $parts[0] ) ? (int) $parts[0] : 1024;
        $height = isset( $parts[1] ) ? (int) $parts[1] : 1024;

        $url = 'https://fal.run/fal-ai/' . rawurlencode( $model );

        // Bug fix (audit r9 spec audit): flux-2-pro は Fal.ai 公式ドキュメント上「zero-configuration」
        // と明記されており、num_inference_steps を受け付けるパラメータに含めていない。現状は
        // 未知パラメータとして無視されるが、将来 strict validation が入ると 400 で弾かれる。
        // flux-2-flex / flux-1.1-pro / flux-2-dev / flux-2-max / flux-1-* は num_inference_steps を
        // 仕様としてサポートしているため引き続き送信する。
        $body = array(
            'prompt'     => $prompt,
            'image_size' => array( 'width' => $width, 'height' => $height ),
            'num_images' => 1,
        );
        if ( strpos( $model, 'flux-2-pro' ) !== 0 ) {
            $body['num_inference_steps'] = isset( $settings['flux_steps'] ) ? (int) $settings['flux_steps'] : 25;
        }

        $response = wp_remote_post( $url, array(
            'timeout' => 120,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Key ' . $settings['flux_api_key'],
            ),
            'body' => wp_json_encode( $body ),
        ) );

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

        $code = wp_remote_retrieve_response_code( $response );
        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }

        if ( $code !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'flux_error', __( 'Flux API error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['images'][0]['url'] ) ) {
            return new WP_Error( 'flux_no_url', __( 'No image URL returned from Flux', 'ai-auto-poster' ) );
        }

        return array(
            'url'       => $data['images'][0]['url'],
            'source'    => 'flux',
            'extension' => 'png',
        );
    }

    // ========== Openverse ==========

    /**
     * Openverse (WordPress.org公式) - APIキー不要
     */
    private static function call_openverse( $query, $settings = array() ) {
        // Bug fix (audit r9): Openverse は aspect_ratio パラメータ (tall/wide/square) を
        // サポートしているが旧実装では未指定だった。ユーザーが image_format=16:9 を選んでも
        // Openverse からは縦長や正方形が返ってしまう問題を修正。
        $stock_or = isset( $settings['stock_orientation'] ) ? (string) $settings['stock_orientation'] : 'landscape';
        $aspect_map = array(
            'landscape' => 'wide',
            'portrait'  => 'tall',
            'squarish'  => 'square',
        );
        $aspect_ratio = isset( $aspect_map[ $stock_or ] ) ? $aspect_map[ $stock_or ] : 'wide';

        $url = add_query_arg( array(
            'q'            => $query,
            'page_size'    => 5,
            'license'      => 'by,by-sa,cc0',
            'mature'       => 'false',
            'aspect_ratio' => $aspect_ratio,
        ), 'https://api.openverse.org/v1/images/' );

        $response = wp_remote_get( $url, array(
            'timeout' => 30,
            'headers' => array(
                'User-Agent' => 'AI Auto Poster WordPress Plugin',
            ),
        ) );

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

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) ) { $data = array(); }
        if ( wp_remote_retrieve_response_code( $response ) !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( 'openverse_error', __( 'Openverse API error:', 'ai-auto-poster' ), $response );
        }

        if ( empty( $data['results'][0]['url'] ) ) {
            /* translators: %s = search query */
            return new WP_Error( 'openverse_no_result', sprintf( __( 'No image found on Openverse: %s', 'ai-auto-poster' ), $query ) );
        }

        $img = $data['results'][0];
        $ext = 'jpg';
        if ( preg_match( '/\.(png|webp|jpeg|jpg)$/i', $img['url'], $m ) ) {
            $ext = strtolower( $m[1] ) === 'jpeg' ? 'jpg' : strtolower( $m[1] );
        }

        return array(
            'url'       => $img['url'],
            'source'    => 'openverse',
            'extension' => $ext,
            'credit'    => isset( $img['creator'] ) ? $img['creator'] : '',
            'license'   => isset( $img['license'] ) ? $img['license'] : '',
        );
    }

    // ========== WordPress メディア登録 ==========

    /**
     * 画像をダウンロードしてメディアライブラリに登録し、アイキャッチに設定
     * @param int    $post_id
     * @param array  $image_result  url or base64 + source + extension
     * @param string $title
     * @return int|WP_Error アタッチメントID
     */
    private static function postprocess_image_file( $path, $filename, $ext, $settings, $do_post_process = false ) {
        if ( ! $do_post_process ) {
            return array( 'path' => $path, 'filename' => $filename, 'extension' => $ext );
        }

        $editor = wp_get_image_editor( $path );
        if ( is_wp_error( $editor ) ) {
            return array( 'path' => $path, 'filename' => $filename, 'extension' => $ext );
        }

        $size      = $editor->get_size();
        $max_width = max( 100, min( 4096, (int) ( $settings['image_max_width'] ?? 1200 ) ) );
        if ( ! empty( $size['width'] ) && $size['width'] > $max_width ) {
            $editor->resize( $max_width, null, false );
        }

        $quality = max( 1, min( 100, (int) ( $settings['image_compress'] ?? 85 ) ) );
        $editor->set_quality( $quality );

        // WebP 変換 (GD または Imagick がサポートする場合のみ)
        // Bug fix (audit r9): 旧コードは function_exists('imagewebp') (GD関数) だけを見ていたため、
        // Imagick バックエンドの環境では WebP delegate がインストールされていても変換がスキップ
        // されていた。WordPress 5.8+ の WP_Image_Editor::supports_mime_type() を優先的に使い、
        // 古い環境では GD/Imagick の能力を直接検査するフォールバックに切り替える。
        $can_webp = false;
        if ( method_exists( $editor, 'supports_mime_type' ) ) {
            $can_webp = (bool) $editor->supports_mime_type( 'image/webp' );
        } else {
            $can_webp = function_exists( 'imagewebp' )
                || ( class_exists( 'Imagick' ) && in_array( 'WEBP', (array) \Imagick::queryFormats(), true ) );
        }
        if ( $can_webp ) {
            $new_path = preg_replace( '/\.[^.]+$/', '.webp', $path );
            $saved    = $editor->save( $new_path, 'image/webp' );
            if ( ! is_wp_error( $saved ) && ! empty( $saved['path'] ) ) {
                wp_delete_file( $path );
                return array(
                    'path'      => $saved['path'],
                    'filename'  => basename( $saved['path'] ),
                    'extension' => 'webp',
                );
            }
        }

        // WebP 非対応環境: リサイズ + 圧縮のみ適用して同形式で保存
        $saved = $editor->save( $path );
        if ( is_wp_error( $saved ) || empty( $saved['path'] ) ) {
            return array( 'path' => $path, 'filename' => $filename, 'extension' => $ext );
        }

        return array(
            'path'      => $saved['path'],
            'filename'  => basename( $saved['path'] ),
            'extension' => $ext,
        );
    }

    private static function attach_image_to_post( $post_id, $image_result, $title, $slug = '' ) {
        require_once ABSPATH . 'wp-admin/includes/file.php';
        require_once ABSPATH . 'wp-admin/includes/media.php';
        require_once ABSPATH . 'wp-admin/includes/image.php';

        $ext = isset( $image_result['extension'] ) ? $image_result['extension'] : 'jpg';

        // ファイル名: 英語スラグベース
        $slug_part = ! empty( $slug ) ? sanitize_file_name( $slug ) : 'post-' . $post_id;
        $filename  = $slug_part . '-' . time() . '.' . $ext;

        if ( ! empty( $image_result['base64'] ) ) {
            $image_data = base64_decode( $image_result['base64'] );
        } elseif ( ! empty( $image_result['url'] ) ) {
            // Bug fix (audit r9 self-audit): SSRF 防御。
            // 旧実装は wp_remote_get + 任意 URL で、攻撃者が API レスポンスを偽装できる
            // 経路 (悪意の API キー、改ざんされたプロキシ、Stock 系 API の改竄) で
            //   - file:///etc/passwd 読み出し
            //   - http://127.0.0.1:8080/admin 内部サービス叩き
            //   - http://169.254.169.254/latest/meta-data/ クラウドメタデータ盗取
            // が可能だった。
            // 1. スキームを http/https に限定
            // 2. 解決後の IP がプライベート/予約レンジに入る場合は拒否
            // 3. wp_safe_remote_get を使い、WP の追加防御 (リダイレクト先のホスト検証等) を効かせる
            $image_url = (string) $image_result['url'];
            if ( ! preg_match( '#^https?://#i', $image_url ) ) {
                return new WP_Error( 'invalid_image_url',
                    /* translators: %s = scheme found in URL */
                    sprintf( __( 'Image URL scheme not allowed: %s', 'ai-auto-poster' ), wp_parse_url( $image_url, PHP_URL_SCHEME ) ?: 'unknown' ) );
            }
            $host = wp_parse_url( $image_url, PHP_URL_HOST );
            if ( ! $host ) {
                return new WP_Error( 'invalid_image_url', __( 'Image URL host is missing.', 'ai-auto-poster' ) );
            }
            // ホストが直接 IP リテラルで書かれているケースをまず弾く (gethostbynamel を経由せずに済む)
            if ( filter_var( $host, FILTER_VALIDATE_IP ) !== false ) {
                if ( filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
                    return new WP_Error( 'invalid_image_url', __( 'Image URL points to a private or reserved IP address.', 'ai-auto-poster' ) );
                }
            } else {
                // ホスト名 → IP 解決して private/reserved レンジを除外。gethostbynamel は失敗時 false。
                // 注: TOCTOU 攻撃 (DNS Rebinding) は完全には防げないが、純粋な内部 IP 直叩きは塞げる。
                $resolved = function_exists( 'gethostbynamel' ) ? @gethostbynamel( $host ) : false;
                if ( is_array( $resolved ) ) {
                    foreach ( $resolved as $ip ) {
                        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) === false ) {
                            return new WP_Error( 'invalid_image_url', __( 'Image URL resolves to a private or reserved IP address.', 'ai-auto-poster' ) );
                        }
                    }
                }
            }
            $dl = wp_safe_remote_get( $image_url, array(
                'timeout' => 60,
                // 画像サイズの上限: 16MB を超えるレスポンスは破棄(メモリ枯渇対策)
                'limit_response_size' => 16 * 1024 * 1024,
                // リダイレクト追跡時の上限を縛る (リダイレクトループや SSRF 多段リダイレクト対策)
                'redirection' => 3,
            ) );
            if ( is_wp_error( $dl ) ) {
                /* translators: %s = error message */
                return new WP_Error( 'download_failed', sprintf( __( 'Image download failed: %s', 'ai-auto-poster' ), $dl->get_error_message() ) );
            }
            $code = wp_remote_retrieve_response_code( $dl );
            if ( (int) $code !== 200 ) {
                /* translators: %d = HTTP status code */
                return new WP_Error( 'download_failed', sprintf( __( 'Image download failed: HTTP %d', 'ai-auto-poster' ), (int) $code ) );
            }
            // Content-Type で画像かどうか軽くチェック(error HTML を画像として保存しないため)
            $ctype = wp_remote_retrieve_header( $dl, 'content-type' );
            if ( $ctype && stripos( $ctype, 'image/' ) === false ) {
                /* translators: %s = Content-Type header value */
                return new WP_Error( 'download_not_image', sprintf( __( 'Downloaded content is not an image (Content-Type: %s)', 'ai-auto-poster' ), $ctype ) );
            }
            $image_data = wp_remote_retrieve_body( $dl );
        } else {
            return new WP_Error( 'no_image_data', __( 'No image data.', 'ai-auto-poster' ) );
        }

        if ( empty( $image_data ) ) {
            return new WP_Error( 'empty_image', __( 'Image data is empty.', 'ai-auto-poster' ) );
        }

        $upload_dir = wp_upload_dir();
        // uploads ディレクトリ自体が書き込み不可・存在しない場合のチェック
        if ( ! empty( $upload_dir['error'] ) ) {
            return new WP_Error( 'upload_dir_error',
                /* translators: %s = WordPress upload directory error message */
                sprintf( __( 'WordPress upload directory error: %s', 'ai-auto-poster' ), $upload_dir['error'] )
            );
        }
        if ( empty( $upload_dir['path'] ) ) {
            return new WP_Error( 'upload_dir_invalid', __( 'WordPress upload directory path is empty.', 'ai-auto-poster' ) );
        }
        $tmp_path   = $upload_dir['path'] . '/' . $filename;

        // WP_Filesystem を使って一時ファイルに書き込む(WordPress.org ガイドライン準拠)
        global $wp_filesystem;
        if ( ! function_exists( 'WP_Filesystem' ) ) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }
        if ( ! WP_Filesystem() ) {
            return new WP_Error( 'write_failed', __( 'Failed to initialize WP_Filesystem.', 'ai-auto-poster' ) );
        }
        if ( ! $wp_filesystem->put_contents( $tmp_path, $image_data, FS_CHMOD_FILE ) ) {
            return new WP_Error( 'write_failed', __( 'Failed to write image file.', 'ai-auto-poster' ) );
        }

        // 一時ファイルから真の MIME を確認(マジックバイト判定)
        $check = wp_check_filetype_and_ext( $tmp_path, $filename );
        if ( ! empty( $check['proper_filename'] ) ) {
            // WordPress が修正したファイル名を採用(拡張子の自動修正)
            $new_path = $upload_dir['path'] . '/' . $check['proper_filename'];
            // rename() は WordPress.org Plugin Check で警告される。
            // WP_Filesystem::move() を使用して規約に準拠する。
            if ( $wp_filesystem->move( $tmp_path, $new_path, true ) ) {
                $tmp_path = $new_path;
                $filename = $check['proper_filename'];
                // Bug fix (audit r9): WordPress が拡張子を修正した場合は $ext も同期する。
                // これがないと後続の $mime_types[$ext] 検索で古い拡張子が使われ、
                // post_mime_type が実ファイルと食い違うアタッチメントが作成されてしまう
                // (例: AIが "png" と申告した実体 jpg ファイルが image/png で登録される)。
                if ( preg_match( '/\.([^.]+)$/', $filename, $m ) ) {
                    $new_ext = strtolower( $m[1] );
                    if ( $new_ext === 'jpeg' ) $new_ext = 'jpg';
                    $ext = $new_ext;
                }
            }
        }
        if ( empty( $check['type'] ) || stripos( (string) $check['type'], 'image/' ) !== 0 ) {
            wp_delete_file( $tmp_path );
            return new WP_Error( 'invalid_image_type', __( 'Downloaded file is not a valid image type.', 'ai-auto-poster' ) );
        }

        $settings  = self::normalize_generation_settings( self::get_settings() );

        // 後処理 (リサイズ + 圧縮 + WebP変換) のトリガは SEO セクションの seo_img_webp に統合。
        // seo_enabled=ON かつ seo_img_webp=ON のときのみ、画像設定の値で後処理を実行する。
        $ai_settings = AIAP_AI_Client::get_settings();
        $do_post_process = ! empty( $ai_settings['seo_enabled'] ) && ! empty( $ai_settings['seo_img_webp'] );

        $processed = self::postprocess_image_file( $tmp_path, $filename, $ext, $settings, $do_post_process );
        if ( is_wp_error( $processed ) ) {
            wp_delete_file( $tmp_path );
            return $processed;
        }
        $tmp_path = $processed['path'];
        $filename = $processed['filename'];
        $ext      = $processed['extension'];

        $mime_types = array(
            'png'  => 'image/png',
            'jpg'  => 'image/jpeg',
            'jpeg' => 'image/jpeg',
            'webp' => 'image/webp',
        );
        $mime = isset( $mime_types[ $ext ] ) ? $mime_types[ $ext ] : 'image/jpeg';

        $attachment = array(
            'post_mime_type' => $mime,
            'post_title'     => sanitize_text_field( $title ),
            'post_content'   => '',
            'post_status'    => 'inherit',
        );

        $attach_id = wp_insert_attachment( $attachment, $tmp_path, $post_id );

        if ( is_wp_error( $attach_id ) ) {
            wp_delete_file( $tmp_path );
            return $attach_id;
        }

        $attach_data = wp_generate_attachment_metadata( $attach_id, $tmp_path );
        wp_update_attachment_metadata( $attach_id, $attach_data );

        set_post_thumbnail( $post_id, $attach_id );

        if ( ! empty( $image_result['credit'] ) ) {
            update_post_meta( $post_id, '_aiap_image_credit', $image_result['credit'] );
        }
        update_post_meta( $post_id, '_aiap_image_source', $image_result['source'] );

        return $attach_id;
    }
}
