<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * 過去投稿との類似回避クラス

 *

 * Step1: PHP簡易チェック（タイトル・キーワード重複率）

 * Step2: AI判定（過去投稿リストと新記事を比較）

 */

class AIAP_Dedup {



    /**

     * PHP簡易チェックの重複率しきい値（本文込み・感度ごと）

     * 「類似」と判定する Jaccard 類似度の下限

     */

    const KEYWORD_THRESHOLDS = array(

        'low'    => 0.4,

        'medium' => 0.3,

        'high'   => 0.2,

    );



    /**

     * タイトル単独の類似度しきい値（感度ごと）

     * タイトルは1文字の漢字(光/春/朝/風/影/音など)も重要語として拾う

     */

    const TITLE_THRESHOLDS = array(

        'low'    => 0.6,

        'medium' => 0.5,

        'high'   => 0.4,

    );



    /**

     * Step2 (AI判定) で AI プロンプトに含める過去投稿の最大件数。

     *

     * get_recent_posts() は 50 件取得するが、Step1 (PHP キーワード比較) と

     * 違い Step2 は全件を AI プロンプトに埋め込むためトークン消費が大きい

     * (50 件 × ~200 文字 ≒ 10,000 文字)。実用上の類似検出は直近 20 件で

     * 十分なため、Step2 ではここで件数を絞ってトークンコストを約 6 割削減。

     *

     * Step1 の検出範囲は影響を受けない (50 件のまま)。

     * 設定UIの「直近20件の投稿と比較」表記とも整合する。

     */

    const AI_CHECK_PAST_LIMIT = 20;



    /**

     * 設定された感度に応じた本文しきい値を取得

     */

    private static function get_keyword_threshold() {

        $settings = AIAP_AI_Client::get_settings();

        $sens = isset( $settings['dedup_sensitivity'] ) ? $settings['dedup_sensitivity'] : 'medium';

        return isset( self::KEYWORD_THRESHOLDS[ $sens ] ) ? self::KEYWORD_THRESHOLDS[ $sens ] : 0.3;

    }



    /**

     * 設定された感度に応じたタイトルしきい値を取得

     */

    private static function get_title_threshold() {

        $settings = AIAP_AI_Client::get_settings();

        $sens = isset( $settings['dedup_sensitivity'] ) ? $settings['dedup_sensitivity'] : 'medium';

        return isset( self::TITLE_THRESHOLDS[ $sens ] ) ? self::TITLE_THRESHOLDS[ $sens ] : 0.5;

    }



    /**

     * AI判定のスコアしきい値を取得

     * 方式1の本文キーワード判定と同じ値を使う。

     * low=40 / medium=30 / high=20

     */

    private static function get_ai_score_threshold() {

        return self::get_keyword_threshold() * 100;

    }



    /**

     * AIスコア判定用の基準説明を返す。

     * 直近ログで「春・夜・自然・静けさ」などの共通テーマだけで 75〜95% と高く出ていたため、

     * 広いテーマ一致では低スコア、具体的な設定・語句・構成の一致でのみ高スコアにする。

     */

    private static function get_ai_score_rubric() {

        return "Scoring rules:

"

             . "0-20: Only broad category overlaps, such as daily life, nature, season, quiet mood, stationery, or blog style.

"

             . "21-40: Some general motifs overlap, but title, setting, viewpoint, and concrete details are different.

"

             . "41-60: Multiple concrete motifs overlap, but wording and structure are still clearly different.

"

             . "61-80: Title words, setting, imagery, or article flow are strongly similar.

"

             . "81-100: Almost the same title, same scene, same motif sequence, or reused wording.

"

             . "Do NOT give a high score just because both articles mention spring, night, flowers, wind, quietness, nature, stationery, coffee, or everyday life.

"

             . "Give a high score only when the concrete scene, title wording, motif combination, or structure is actually close.

";

    }



    /**

     * 過去投稿（直近1週間）を取得

     *

     * @return array array( array( 'title' => ..., 'excerpt' => ... ), ... )

     */

    public static function get_recent_posts() {

        // TZ 依存解消: 旧実装の "1 week ago" は PHP のデフォルト TZ (UTC) で評価され、

        // サイト TZ と乖離した取得範囲になっていた。WP のサイト TZ に揃えた絶対日付を渡す。

        // current_time( 'timestamp' ) はサイト TZ 基準の UNIX 秒を返す。

        // inclusive はデフォルト (false = strict greater-than) のままにして、

        // 旧 "1 week ago" 文字列の境界挙動と整合させる (仕様変更を避ける)。

        $week_ago_site = gmdate( 'Y-m-d H:i:s', current_time( 'timestamp' ) - 7 * DAY_IN_SECONDS );

        $args = array(

            'post_type'      => 'post',

            'post_status'    => array( 'publish', 'draft', 'pending' ),

            'posts_per_page' => 50,

            'date_query'     => array(

                array(

                    'after' => $week_ago_site,

                ),

            ),

            'orderby'        => 'date',

            'order'          => 'DESC',

        );



        $posts  = get_posts( $args );

        $result = array();



        foreach ( $posts as $post ) {

            $content = wp_strip_all_tags( $post->post_content );

            $result[] = array(

                'title'   => $post->post_title,

                'excerpt' => AIAP_Compat::mb_substr( $content, 0, 300 ),

            );

        }



        return $result;

    }



    /**

     * 新記事が過去投稿と類似しているかチェック

     *

     * step1, step2 の有効/無効を設定で制御。

     * 両方ONの場合はAND条件（両方「類似」と判定した場合のみ再生成）。

     * 片方のみONの場合はそのステップ単独で判定。

     *

     * @param string $new_title   新記事のタイトル

     * @param string $new_content 新記事の本文

     * @return array array( 'is_similar' => bool, 'reason' => string, 'method' => string )

     */

    public static function check( $new_title, $new_content ) {

        $recent = self::get_recent_posts();



        if ( empty( $recent ) ) {

            return array(

                'is_similar' => false,

                'reason'     => __( 'No posts in the past week, skipped', 'ai-auto-poster' ),

                'method'     => 'none',

            );

        }



        $settings   = AIAP_AI_Client::get_settings();

        $use_step1  = ! empty( $settings['dedup_step1'] );

        $use_step2  = ! empty( $settings['dedup_step2'] );

        $both       = $use_step1 && $use_step2;



        // どちらも無効なら通過

        if ( ! $use_step1 && ! $use_step2 ) {

            return array(

                'is_similar' => false,

                'reason'     => __( 'Both Method 1 and Method 2 are off', 'ai-auto-poster' ),

                'method'     => 'none',

            );

        }



        $step1_similar = false;

        $step1_result  = null;

        $step2_similar = false;

        $step2_result  = null;



        // Step1: PHP簡易チェック(先に実行)

        if ( $use_step1 ) {

            $step1_result  = self::check_keywords( $new_title, $new_content, $recent );

            $step1_similar = $step1_result['is_similar'];

        }



        // Step2: AI判定

        if ( $use_step2 ) {

            $step2_result  = self::check_with_ai( $new_title, $new_content, $recent );

            $step2_similar = $step2_result['is_similar'];

        }



        // 両方ONの場合はAND判定に統一する。

        // 旧実装はStep1で類似なら即再生成していたため、AI判定を有効にしても

        // キーワード判定だけで毎回リトライ上限まで進むことがあった。

        if ( $both ) {

            if ( $step1_similar && $step2_similar ) {

                return array(

                    'is_similar' => true,

                    'reason'     => trim( ( $step1_result['reason'] ?? '' ) . ' / ' . ( $step2_result['reason'] ?? '' ), ' /' ),

                    'method'     => 'keyword+ai',

                );

            }

            return array(

                'is_similar' => false,

                'reason'     => __( 'Method 1 and Method 2 did not both flag as similar', 'ai-auto-poster' ),

                'method'     => 'keyword+ai',

            );

        }



        // 片方のみON

        if ( $use_step1 && $step1_similar ) {

            return $step1_result;

        }

        if ( $use_step2 && $step2_similar ) {

            return $step2_result;

        }



        return array(

            'is_similar' => false,

            'reason'     => '',

            'method'     => $use_step1 ? 'keyword' : 'ai',

        );

    }



    /**

     * check() の判定結果と試行状態から、次に取るべきアクションを 3 種に分類する。

     *

     * 旧実装では同じ分岐と同じログ出力が手動 (admin-runner.php) と自動 (poster.php)

     * の dedup ループ周辺に手書きで重複していた (DRY 違反)。両経路は制御フローが

     * 違う (自動: while ループ完結 / 手動: AJAX 往復) ため「ループ自体」は共通化

     * できないが、判定結果の分類とログ出力は本メソッドに集約できる。

     *

     * build_retry_prompt の呼び出し自体は経路ごとに引数 ($original_prompt /

     * $task['prompt']) が異なるため呼び元に残し、本メソッドは「次の意思決定」と

     * 「ログ」のみを担当する (単一責任)。

     *

     * @param array  $dedup_result self::check() の戻り値

     *                              (キー: is_similar, reason, method)

     * @param int    $attempt      現在の試行回数 (1 始まり)

     * @param int    $max_retry    リトライ最大回数 (= dedup_max_retry)

     * @param string $source       ログの src 識別子 ('src_manual' /

     *                              'src_auto_cron_<task_id>' 等)

     * @return string 'accept'          ... 非類似のため採用 (ループを抜ける)

     *                'give_up_accept'  ... 類似だが上限到達のため諦めて採用

     *                'retry'           ... 類似のためリトライ (build_retry_prompt

     *                                       を呼ぶこと)

     */

    public static function classify_attempt_outcome( $dedup_result, $attempt, $max_retry, $source ) {

        $attempt    = (int) $attempt;

        $max_retry  = (int) $max_retry;

        $method     = is_array( $dedup_result ) && isset( $dedup_result['method'] )

            ? (string) $dedup_result['method'] : '';



        // 非類似 → 採用

        if ( empty( $dedup_result['is_similar'] ) ) {

            // 初回試行で成功した場合はログ不要 (通常フロー)。リトライ末に成功した

            // 場合のみ「N 回目で類似回避に成功」を残す (旧実装と同じ条件)。

            if ( $attempt > 1 ) {

                AIAP_Logger::add( 'info', 'log_dedup_passed', array(

                    $source, $attempt, $method,

                ) );

            }

            return 'accept';

        }



        // 類似だがリトライ上限到達 → 諦めて採用

        if ( $attempt > $max_retry ) {

            AIAP_Logger::add( 'info', 'log_dedup_max_reached', array( $source, $max_retry ) );

            return 'give_up_accept';

        }



        // 類似 → リトライ

        $reason_text = ( is_array( $dedup_result ) && isset( $dedup_result['reason'] )

            && is_string( $dedup_result['reason'] ) ) ? $dedup_result['reason'] : '';

        AIAP_Logger::add( 'info', 'log_dedup_retry', array(

            $source, $attempt, $max_retry, $method,

            AIAP_Compat::mb_substr( $reason_text, 0, 80 ),

        ) );

        return 'retry';

    }



    /**

     * Step1: PHP簡易チェック（キーワード重複率）

     */

    private static function check_keywords( $new_title, $new_content, $recent ) {

        $new_words = self::extract_keywords( $new_title . ' ' . AIAP_Compat::mb_substr( wp_strip_all_tags( $new_content ), 0, 500 ) );

        $new_title_words = self::extract_title_keywords( $new_title );

        $title_threshold   = self::get_title_threshold();

        $keyword_threshold = self::get_keyword_threshold();



        if ( empty( $new_words ) && empty( $new_title_words ) ) {

            return array( 'is_similar' => false, 'reason' => '', 'method' => 'keyword' );

        }



        foreach ( $recent as $post ) {

            // タイトル完全一致チェック(最優先)

            if ( AIAP_Compat::mb_strtolower( $new_title ) === AIAP_Compat::mb_strtolower( $post['title'] ) ) {

                return array(

                    'is_similar' => true,

                    /* translators: %s = matched post title */

                    'reason'     => sprintf( __( 'Title exact match: %s', 'ai-auto-poster' ), AIAP_Compat::mb_substr( $post['title'], 0, 40 ) ),

                    'method'     => 'keyword',

                );

            }



            // タイトル類似度チェック(1文字漢字も拾う別ロジック)

            $old_title_words = self::extract_title_keywords( $post['title'] );

            if ( ! empty( $new_title_words ) && ! empty( $old_title_words ) ) {

                $t_intersection = count( array_intersect( $new_title_words, $old_title_words ) );

                $t_union        = count( array_unique( array_merge( $new_title_words, $old_title_words ) ) );

                $t_similarity   = $t_union > 0 ? $t_intersection / $t_union : 0;



                // 1語だけの一致（例: 春、風、光、自然）で毎回「類似」にならないようにする。

                // 完全一致は上で判定済みなので、タイトル類似は「2語以上一致」または「4文字以上の特徴語一致」を必須にする。

                $has_strong_title_word = false;

                foreach ( array_intersect( $new_title_words, $old_title_words ) as $matched_title_word ) {

                    if ( AIAP_Compat::mb_strlen( $matched_title_word ) >= 4 ) {

                        $has_strong_title_word = true;

                        break;

                    }

                }

                $title_hit_count = $t_intersection;



                if ( $t_similarity >= $title_threshold && ( $title_hit_count >= 2 || $has_strong_title_word ) ) {

                    return array(

                        'is_similar' => true,

                        /* translators: 1: similarity percent, 2: threshold percent, 3: similar post title */

                        'reason'     => sprintf( __( 'Title similarity %1$.0f%% (threshold %2$.0f%%) — similar post: %3$s', 'ai-auto-poster' ), 

                            $t_similarity * 100,

                            $title_threshold * 100,

                            AIAP_Compat::mb_substr( $post['title'], 0, 40 )

                        ),

                        'method'     => 'keyword',

                    );

                }

            }



            // 本文込みJaccard類似度

            $old_words = self::extract_keywords( $post['title'] . ' ' . $post['excerpt'] );



            if ( empty( $old_words ) ) {

                continue;

            }



            $intersection = count( array_intersect( $new_words, $old_words ) );

            $union        = count( array_unique( array_merge( $new_words, $old_words ) ) );

            $similarity   = $union > 0 ? $intersection / $union : 0;



            // 自然・日常ブログでは共通語が出やすいため、率だけでなく一致語数も見る。

            // 1〜2語の共通だけでリトライ上限まで走る誤判定を防ぐ。

            $common_word_count = $intersection;

            if ( $similarity >= $keyword_threshold && $common_word_count >= 3 ) {

                return array(

                    'is_similar' => true,

                    /* translators: 1: overlap percent, 2: threshold percent, 3: similar post title */

                    'reason'     => sprintf( __( 'Keyword overlap %1$.0f%% (threshold %2$.0f%%) — similar post: %3$s', 'ai-auto-poster' ), 

                        $similarity * 100,

                        $keyword_threshold * 100,

                        AIAP_Compat::mb_substr( $post['title'], 0, 40 )

                    ),

                    'method'     => 'keyword',

                );

            }

        }



        return array( 'is_similar' => false, 'reason' => '', 'method' => 'keyword' );

    }



    /**

     * AI類似判定プロンプトを言語別に構築

     *

     * @param string $lang        'ja' | 'en' | 'zh' | 'ko' | 'es'

     * @param string $past_list   "1. タイトル\n2. ...\n"

     * @param string $new_title

     * @param string $new_excerpt

     * @return string

     */

    private static function build_ai_check_prompt( $past_list, $new_title, $new_excerpt ) {

        $threshold = self::get_ai_score_threshold();



        // AI判定も方式1と同じ3段階スコア方式に統一する。

        // 直近ログでは「春・夜・自然・静けさ」のような広いテーマ一致だけで 75〜95% になっていた。

        // そのため、広いテーマ一致は低スコア、具体的な場面・タイトル・構成一致だけを高スコアにする基準を明示する。

        $rubric = self::get_ai_score_rubric();



        return "Score how similar the following New Article is to the Past Articles from 0 to 100.

"

             . "0 means completely different. 100 means almost the same article.

"

             . "Use the similarity threshold {$threshold}. If score is below {$threshold}, it is NOT similar.



"

             . $rubric . "

"

             . "Important: judge concrete duplication, not broad genre similarity.

"

             . "If the overlap is only general mood or seasonal/nature blog atmosphere, keep the score under the threshold.



"

             . "[Past Articles (last 7 days)]

{$past_list}

"

             . "[New Article]

Title: {$new_title}

Excerpt: {$new_excerpt}



"

             . "Respond ONLY with JSON. No markdown. No other text.

"

             . '{"score": 0, "reason": "one short reason"}';

    }



    /**

     * タイトル用キーワード抽出

     * 本文用と違い、1文字の漢字も重要語として扱う

     * (「光」「春」「朝」「風」「影」「音」「空」「海」など短い重要語を拾うため)

     */

    private static function extract_title_keywords( $title ) {

        $title = AIAP_Compat::mb_strtolower( $title );

        $title = wp_strip_all_tags( $title );

        $title = html_entity_decode( $title, ENT_QUOTES, 'UTF-8' );



        // カタカナは2文字以上、漢字は1文字以上、ハングルは2文字以上

        preg_match_all( '/[\x{30A0}-\x{30FF}]{2,}|[\x{4E00}-\x{9FFF}]+|[\x{AC00}-\x{D7AF}]{2,}/u', $title, $ja_matches );

        // 欧文(英・西など)3文字以上の単語(アクセント付き含む)

        preg_match_all( '/[\p{L}]{3,}/u', $title, $en_matches );



        $words = array_merge(

            isset( $ja_matches[0] ) ? $ja_matches[0] : array(),

            isset( $en_matches[0] ) ? $en_matches[0] : array()

        );



        return array_unique( array_values( $words ) );

    }



    /**

     * テキストからキーワードを抽出

     */

    private static function extract_keywords( $text ) {

        $text = AIAP_Compat::mb_strtolower( $text );

        $text = wp_strip_all_tags( $text );



        // HTMLエンティティ除去

        $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );



        // 日本語: 2文字以上の連続するカタカナ・漢字を抽出

        // 韓国語: 2文字以上の連続するハングル(中国語の漢字は \x{4E00}-\x{9FFF} で既にカバー)

        preg_match_all( '/[\x{30A0}-\x{30FF}]{2,}|[\x{4E00}-\x{9FFF}]{2,}|[\x{AC00}-\x{D7AF}]{2,}/u', $text, $ja_matches );



        // 英語・スペイン語など欧文: 3文字以上の単語を抽出

        // (アクセント付き文字も含めるため Unicode property を使う)

        preg_match_all( '/[\p{L}]{3,}/u', $text, $en_matches );



        $words = array_merge(

            isset( $ja_matches[0] ) ? $ja_matches[0] : array(),

            isset( $en_matches[0] ) ? $en_matches[0] : array()

        );



        // ストップワード除去(言語別の頻出機能語をまとめて除外)

        $stop = array(

            // 英語

            'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'has', 'her', 'was',

            'one', 'our', 'out', 'this', 'that', 'with', 'have', 'from', 'they', 'been', 'said',

            'each', 'which', 'their', 'about', 'would', 'there',

            // 日本語

            'する', 'ある', 'できる', 'なる', 'れる', 'られる', 'ない', 'です', 'ます',

            // 中国語(頻出助詞・代名詞)

            '这个', '那个', '我们', '他们', '什么', '怎么', '可以', '没有', '已经', '因为', '所以',

            '但是', '如果', '这样', '那样', '一个', '一些',

            // 韓国語(頻出語)

            '그리고', '하지만', '그러나', '때문에', '그래서', '이것', '저것', '우리', '그들', '하는',

            '있는', '없는', '되는', '같은',

            // スペイン語(頻出冠詞・前置詞・接続詞)

            'que', 'los', 'las', 'pero', 'por', 'para', 'con', 'sin', 'son', 'una', 'uno',

            'sus', 'esta', 'este', 'esto', 'como', 'porque', 'cuando', 'donde', 'todos', 'nosotros',

        );

        $words = array_diff( $words, $stop );



        return array_unique( array_values( $words ) );

    }



    /**

     * Step2: AI判定

     */

    private static function check_with_ai( $new_title, $new_content, $recent ) {

        // 過去投稿リストを作成。

        // タイトルだけだと「春の夜」などの語感だけでAIスコアが高く出やすいため、本文抜粋も渡して具体的に比較させる。

        // get_recent_posts は Step1 用に最大 50 件取得するが、Step2 は AI プロンプトに

        // 埋め込むためトークンコストが大きい。直近 AI_CHECK_PAST_LIMIT 件に絞る。

        $recent_for_ai = array_slice( $recent, 0, self::AI_CHECK_PAST_LIMIT );

        $past_list = '';

        foreach ( $recent_for_ai as $i => $post ) {

            $num     = $i + 1;

            $title   = isset( $post['title'] ) ? trim( wp_strip_all_tags( (string) $post['title'] ) ) : '';

            $excerpt = isset( $post['excerpt'] ) ? trim( wp_strip_all_tags( (string) $post['excerpt'] ) ) : '';

            $excerpt = AIAP_Compat::mb_substr( $excerpt, 0, 160 );

            $past_list .= "{$num}. Title: {$title}

Excerpt: {$excerpt}

";

        }



        $new_excerpt = AIAP_Compat::mb_substr( wp_strip_all_tags( $new_content ), 0, 500 );



        // プロンプトは英語固定(言語に依存しない、AIは英語の指示で判定可能)

        $settings = AIAP_AI_Client::get_settings();

        $prompt   = self::build_ai_check_prompt( $past_list, $new_title, $new_excerpt );



        // 現在のテキストAIで判定

        $provider = $settings['provider'];



        // 簡易的にgenerate系を流用（max_tokensを小さくして高速化）

        // 429 等の一時エラーには Retry-After を尊重して最大2回までリトライする

        // (リトライしないと dedup がフェイルオープンで類似記事を通してしまう)

        $raw = self::call_mini_with_retry( $provider, $settings, $prompt );



        if ( is_wp_error( $raw ) || empty( $raw ) ) {

            // AIエラー時は類似なしとして通過させる

            $err_msg = is_wp_error( $raw )

                ? $raw->get_error_message()

                : __( 'empty response', 'ai-auto-poster' );

            return array(

                'is_similar' => false,

                /* translators: %s = error message from AI provider */

                'reason'     => sprintf( __( 'AI judgment error (treated as pass): %s', 'ai-auto-poster' ), $err_msg ),

                'method'     => 'ai_error',

            );

        }



        // JSONパース(中括弧バランスでパース範囲を決める)

        $cleaned = preg_replace( '/^```(?:json)?\s*/m', '', $raw );

        $cleaned = preg_replace( '/\s*```\s*$/m', '', $cleaned );

        $cleaned = trim( $cleaned );



        $start = strpos( $cleaned, '{' );

        if ( $start !== false ) {

            // 中括弧の対応バランスを取って最外周のJSONを抽出する

            $len = strlen( $cleaned );

            $depth = 0; $in_string = false; $escape = false; $end = -1;

            for ( $i = $start; $i < $len; $i++ ) {

                $ch = $cleaned[ $i ];

                if ( $escape ) { $escape = false; continue; }

                if ( $in_string ) {

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

                    if ( $ch === '"' )  { $in_string = false; }

                    continue;

                }

                if ( $ch === '"' ) { $in_string = true; continue; }

                if ( $ch === '{' ) { $depth++; }

                elseif ( $ch === '}' ) {

                    $depth--;

                    if ( $depth === 0 ) { $end = $i; break; }

                }

            }

            $json_str = $end > 0 ? substr( $cleaned, $start, $end - $start + 1 ) : substr( $cleaned, $start );

            $data = json_decode( $json_str, true );

            if ( is_array( $data ) && isset( $data['score'] ) ) {

                // reason は文字列以外で返ってくることがあるので強制的に文字列化

                $reason_raw = $data['reason'] ?? '';

                if ( is_array( $reason_raw ) || is_object( $reason_raw ) ) {

                    $reason_raw = wp_json_encode( $reason_raw );

                }



                $score = is_numeric( $data['score'] ) ? (float) $data['score'] : 0;

                $score = max( 0, min( 100, $score ) );

                $threshold = self::get_ai_score_threshold();



                return array(

                    'is_similar' => $score >= $threshold,

                    'reason'     => sprintf(

                        'AI similarity score %.0f%% (threshold %.0f%%) — %s',

                        $score,

                        $threshold,

                        (string) $reason_raw

                    ),

                    'method'     => 'ai',

                );

            }

        }



        return array(

            'is_similar' => false,

            'reason'     => __( 'Failed to parse AI response (treated as pass)', 'ai-auto-poster' ),

            'method'     => 'ai_parse_error',

        );

    }



    // ========== 軽量AI呼び出し ==========



    /**

     * 軽量 AI 呼び出しを最大 N 回までリトライするラッパー

     *

     * 429 や 503 のような一時的エラー時のみリトライし、401 / 400 等は即返却する。

     * Retry-After ヘッダがあればそれを尊重(上限 30 秒)、なければ線形バックオフ(1秒)。

     *

     * @param string $provider テキスト AI プロバイダ名

     * @param array  $settings ai_client 設定

     * @param string $prompt   プロンプト

     * @return string|WP_Error 応答テキスト or WP_Error

     */

    private static function call_mini_with_retry( $provider, $settings, $prompt ) {

        $max_attempts = 2;

        $result = null;

        for ( $attempt = 1; $attempt <= $max_attempts; $attempt++ ) {

            switch ( $provider ) {

                case 'claude':

                    $result = self::call_claude_mini( $settings, $prompt );

                    break;

                case 'openai':

                    $result = self::call_openai_mini( $settings, $prompt );

                    break;

                case 'gemini':

                    $result = self::call_gemini_mini( $settings, $prompt );

                    break;

                default:

                    // Mistral/Groq/DeepSeek/OpenRouterはOpenAI互換

                    $result = self::call_openai_compatible_mini( $settings, $prompt );

                    break;

            }



            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;



            // http_code=0 (HTTP 通信失敗等) はリトライ可能扱い

            if ( $http_code !== 0 && ! AIAP_AI_Errors::is_retryable_http_code( $http_code ) ) {

                return $result;

            }



            if ( $attempt < $max_attempts ) {

                $sleep = $retry_after > 0 ? min( 30, $retry_after ) : 1;

                sleep( $sleep );

            }

        }

        return $result;

    }



    /**

     * Bug fix (audit r9 self-audit, #38): AI mini API レスポンス検証の重複解消。

     *

     * 4 つの call_*_mini 関数 (Claude / OpenAI / Gemini / OpenAI 互換) の末尾で

     * 同じパターン (is_wp_error → response_code → build_api_error_wp_error → json_decode)

     * が重複していたため、helper に集約。jscpd 検出 #10/#11 が該当。

     *

     * AIAP_Http 共通化 (audit r10): AIAP_Http::post_json の戻り値

     *   ['code'=>int, 'body'=>string, 'json'=>mixed, 'response'=>array] | WP_Error

     * を受け取るシグネチャに変更。

     *

     * @param array|WP_Error $r          AIAP_Http::post_json の戻り値

     * @param string         $error_code WP_Error のコード (e.g. 'claude_error')

     * @param string         $label      プロバイダラベル (e.g. 'Claude')

     * @return array|WP_Error デコード済み JSON 連想配列、または WP_Error

     */

    private static function decode_mini_response( $r, $error_code, $label ) {

        if ( is_wp_error( $r ) ) {

            return $r;

        }

        if ( (int) $r['code'] !== 200 ) {

            return AIAP_AI_Errors::build_api_error_wp_error( $error_code, $label, $r['response'] );

        }

        return is_array( $r['json'] ) ? $r['json'] : array();

    }



    private static function call_claude_mini( $settings, $prompt ) {

        if ( empty( $settings['claude_api_key'] ) ) {

            return new WP_Error( 'no_key', __( 'Claude API key not set', 'ai-auto-poster' ) );

        }

        $r = AIAP_Http::post_json( 'https://api.anthropic.com/v1/messages', array(

            'model'      => $settings['claude_model'],

            'max_tokens' => 150,

            'messages'   => array(

                array( 'role' => 'user',      'content' => $prompt ),

                // assistant プレフィル: 応答を必ず '{' から始めさせ、思考過程の混入を防ぐ。

                // 戻り値先頭で '{' を補完する。

                array( 'role' => 'assistant', 'content' => '{' ),

            ),

        ), array(

            'x-api-key'         => $settings['claude_api_key'],

            'anthropic-version' => '2023-06-01',

        ), 30 );

        $data = self::decode_mini_response( $r, 'claude_error', 'Claude' );

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

        $body_text = AIAP_AI_Providers::scalar_text( $data['content'][0]['text'] ?? null );

        // プレフィルで送った '{' は応答に含まれないため、先頭に補完する。

        return ( $body_text === '' ) ? '' : '{' . $body_text;

    }



    /**

     * Bug fix (audit r9 self-audit, #40): OpenAI 互換 chat completion body の構築を共通化する。

     *

     * call_openai_mini と call_openai_compatible_mini (Mistral/Groq/DeepSeek/OpenRouter)

     * の双方で、model + messages + response_format + reasoning モデルの token_param 切替

     * という同一の body 構築ロジックがコピペされていた (jscpd 残存検出)。

     *

     * @param string $model  e.g. 'gpt-4o', 'mistral-large-latest'

     * @param string $prompt user role の本文

     * @param array  $caps   AIAP_AI_Providers::get_model_capabilities() の戻り値

     * @return array OpenAI 互換 /chat/completions の body

     */

    private static function build_openai_chat_body( $model, $prompt, $caps ) {


        $output_tokens = ! empty( $caps['token_param'] )


            ? ( ( ( $caps['family'] ?? '' ) === 'openai-reasoning' ) ? 2000 : 150 )


            : null;


        return AIAP_AI_Providers::build_openai_chat_completion_body(


            $model,


            array( array( 'role' => 'user', 'content' => $prompt ) ),


            $caps,


            $output_tokens


        );


    }




    private static function call_openai_mini( $settings, $prompt ) {

        if ( empty( $settings['openai_api_key'] ) ) {

            return new WP_Error( 'no_key', __( 'OpenAI API key not set', 'ai-auto-poster' ) );

        }

        $model = $settings['openai_model'];

        $caps  = AIAP_AI_Providers::get_model_capabilities( 'openai', $model );

        $body  = self::build_openai_chat_body( $model, $prompt, $caps );

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

        $data = self::decode_mini_response( $r, 'openai_error', 'OpenAI' );

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

        return AIAP_AI_Providers::scalar_text( $data['choices'][0]['message']['content'] ?? null );

    }



    private static function call_gemini_mini( $settings, $prompt ) {

        if ( empty( $settings['gemini_api_key'] ) ) {

            return new WP_Error( 'no_key', __( 'Gemini API key not set', 'ai-auto-poster' ) );

        }

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

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

            // JSON 出力強制 + 出力上限。dedup 判定用の軽量呼び出しなので 150 トークンで十分。

            // Bug fix: 旧実装は maxOutputTokens 未指定だったため Gemini のデフォルト

            // (8192 程度) で動作し、dedup 用途として課金が過剰になっていた。call_gemini

            // (本体) と同じく明示する。

            'generationConfig' => array(

                'responseMimeType' => 'application/json',

                'maxOutputTokens'  => 150,

            ),

        ), array(), 30 );

        $data = self::decode_mini_response( $r, 'gemini_error', 'Gemini' );

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

        return AIAP_AI_Providers::scalar_text( $data['candidates'][0]['content']['parts'][0]['text'] ?? null );

    }



    private static function call_openai_compatible_mini( $settings, $prompt ) {

        // Mistral / Groq / DeepSeek / OpenRouter は OpenAI 互換 API。

        // エンドポイント定義は AIAP_AI_Providers::get_openai_compatible_endpoints() に集約

        // (CLAUDE.md「同一テーブル二重定義禁止」)。

        $endpoints = AIAP_AI_Providers::get_openai_compatible_endpoints();



        $provider = $settings['provider'];

        if ( ! isset( $endpoints[ $provider ] ) ) {

            /* translators: %s = provider name */

            return new WP_Error( 'unknown', sprintf( __( 'Unknown provider: %s', 'ai-auto-poster' ), $provider ) );

        }



        $ep  = $endpoints[ $provider ];

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

        if ( empty( $key ) ) {

            /* translators: %s = provider name */

            return new WP_Error( 'no_key', sprintf( __( '%s API key not set', 'ai-auto-poster' ), $provider ) );

        }



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



        // OpenRouterがOpenAIのgpt-5/o1/o3/o4をプロキシ配信するケースに備えて

        // プロバイダごとのcapabilitiesでtoken_paramを切り替える。

        $caps = AIAP_AI_Providers::get_model_capabilities( $provider, $model );

        $body = self::build_openai_chat_body( $model, $prompt, $caps );

        $headers = AIAP_Http::bearer( $key );



        if ( $provider === 'openrouter' ) {

            $headers['HTTP-Referer'] = home_url();

            $headers['X-Title']      = 'AI Auto Poster';

        }



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

        $data = self::decode_mini_response( $r, $provider . '_error', ucfirst( $provider ) );

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

        return AIAP_AI_Providers::scalar_text( $data['choices'][0]['message']['content'] ?? null );

    }



    /**

     * 再生成用のプロンプト修正

     * 過去記事との類似を回避する指示を追加

     */

    public static function build_retry_prompt( $original_prompt, $reason, $recent, $generated = array() ) {

        $reason = trim( wp_strip_all_tags( (string) $reason ) );

        if ( $reason === '' ) {

            $reason = 'May be similar to a recent post.';

        }



        $generated_title   = isset( $generated['title'] ) ? trim( wp_strip_all_tags( (string) $generated['title'] ) ) : '';

        $generated_content = isset( $generated['content'] ) ? wp_strip_all_tags( (string) $generated['content'] ) : '';

        $generated_excerpt = $generated_content !== '' ? AIAP_Compat::mb_substr( $generated_content, 0, 240 ) : '';



        $similar_post    = self::find_most_similar_recent_post( $generated_title, $generated_content, $recent );

        $similar_title   = isset( $similar_post['title'] ) ? trim( wp_strip_all_tags( (string) $similar_post['title'] ) ) : '';

        $similar_excerpt = isset( $similar_post['excerpt'] ) ? trim( wp_strip_all_tags( (string) $similar_post['excerpt'] ) ) : '';

        $similar_excerpt = $similar_excerpt !== '' ? AIAP_Compat::mb_substr( $similar_excerpt, 0, 240 ) : '';



        $common_keywords = self::extract_common_retry_keywords(

            $generated_title . ' ' . $generated_excerpt,

            $similar_title . ' ' . $similar_excerpt

        );

        $avoid_terms = self::extract_retry_avoid_terms( $generated_title, $similar_title, $reason, $common_keywords );

        $avoid_terms = array_slice( array_values( array_unique( array_filter( $avoid_terms ) ) ), 0, 8 );



        $past_titles = array();

        foreach ( $recent as $post ) {

            if ( ! empty( $post['title'] ) ) {

                $past_titles[] = '- ' . trim( wp_strip_all_tags( (string) $post['title'] ) );

            }

        }

        $past_list = implode( "\n", array_slice( $past_titles, 0, 5 ) );



        // 指示文は英語固定。理由は generate() 側のシステムプロンプトと同じ:

        //   - AI は英語のメタ指示でも対象言語で出力できる

        //   - 日本語固定にすると、英語/中国語/韓国語サイトで日本語混入 + トークン浪費 +

        //     AI が「日本語で書け」と誤解するリスクがあった

        // 元の作成指示は最後に連結し、ユーザー指定の主題はそのまま渡る。

        // パース失敗対策: 再生成プロンプトを長くしすぎると、生成 AI が JSON 形式を

        // 守らず空応答 / 非 JSON を返すことがあるため、差分材料は短い抜粋・禁止語句に絞る。

        $addendum = "[REGENERATION CONDITIONS]\n"

                  . "To reduce similarity with recent posts, avoid the items below and write in a different direction.\n";



        if ( $reason !== '' ) {

            $addendum .= "Similarity reason: {$reason}\n";

        }

        if ( $generated_title !== '' ) {

            $addendum .= "Previous title: {$generated_title}\n";

        }

        if ( $similar_title !== '' ) {

            $addendum .= "Closest recent post: {$similar_title}\n";

        }

        if ( ! empty( $avoid_terms ) ) {

            $addendum .= "Avoid these terms / motifs: " . implode( ' / ', $avoid_terms ) . "\n";

        }

        if ( $generated_excerpt !== '' ) {

            $addendum .= "Previous excerpt: {$generated_excerpt}\n";

        }

        if ( $similar_excerpt !== '' ) {

            $addendum .= "Recent post excerpt: {$similar_excerpt}\n";

        }

        if ( $past_list !== '' ) {

            $addendum .= "Recent post titles to avoid:\n{$past_list}\n";

        }



        $addendum .= "\n[REGENERATION RULES]\n"

                  . "- Use a different central topic word in the title.\n"

                  . "- Avoid the same time of day, place, props, natural objects, and scene descriptions.\n"

                  . "- Vary the introduction, paragraph structure, and sentence endings.\n"

                  . "- Keep all original instructions intact.\n"

                  . "- Strictly follow the output format defined by the regular generation pipeline.\n\n"

                  . "[ORIGINAL INSTRUCTIONS]\n";



        return $addendum . $original_prompt;

    }



    /**

     * 再生成用に、直近記事の中から一番近い記事を選ぶ。

     */

    private static function find_most_similar_recent_post( $generated_title, $generated_content, $recent ) {

        if ( empty( $recent ) ) {

            return array();

        }



        $new_words = self::extract_keywords( $generated_title . ' ' . AIAP_Compat::mb_substr( wp_strip_all_tags( (string) $generated_content ), 0, 800 ) );

        $best_post = array();

        $best_score = -1;



        foreach ( $recent as $post ) {

            $old_text  = ( $post['title'] ?? '' ) . ' ' . ( $post['excerpt'] ?? '' );

            $old_words = self::extract_keywords( $old_text );

            if ( empty( $new_words ) || empty( $old_words ) ) {

                continue;

            }

            $common = array_intersect( $new_words, $old_words );

            $union  = array_unique( array_merge( $new_words, $old_words ) );

            $score  = count( $union ) > 0 ? count( $common ) / count( $union ) : 0;

            // タイトル一致要素を少し重くする

            $title_common = array_intersect( self::extract_title_keywords( $generated_title ), self::extract_title_keywords( $post['title'] ?? '' ) );

            $score += count( $title_common ) * 0.05;



            if ( $score > $best_score ) {

                $best_score = $score;

                $best_post  = $post;

            }

        }



        return ! empty( $best_post ) ? $best_post : ( $recent[0] ?? array() );

    }



    /**

     * 再生成時に避ける語句・モチーフを抽出する。

     * ログで高スコアになっていたタイトル語・理由文の具体語も拾う。

     */

    private static function extract_retry_avoid_terms( $generated_title, $similar_title, $reason, $common_keywords = array() ) {

        $terms = array();



        $terms = array_merge( $terms, self::extract_title_keywords( (string) $generated_title ) );

        $terms = array_merge( $terms, self::extract_title_keywords( (string) $similar_title ) );

        $terms = array_merge( $terms, self::extract_keywords( (string) $reason ) );

        if ( is_array( $common_keywords ) ) {

            $terms = array_merge( $terms, $common_keywords );

        }



        $terms = array_map( function( $term ) {

            $term = trim( wp_strip_all_tags( (string) $term ) );

            $term = trim( $term );

            $term = trim( $term, '、。,.!！?？:：;；()（）[]【】『』' );

            return $term;

        }, $terms );



        $terms = array_filter( $terms, function( $term ) {

            if ( $term === '' ) {

                return false;

            }

            // かな1文字や英字2文字以下はノイズになりやすい。漢字1文字は春/夜/月/花/風など重要語として残す。

            if ( preg_match( '/^[ぁ-んァ-ンー]$/u', $term ) ) {

                return false;

            }

            if ( preg_match( '/^[a-zA-Z]{1,2}$/u', $term ) ) {

                return false;

            }

            return true;

        } );



        $terms = array_values( array_unique( $terms ) );



        // 長い語を優先しつつ、短い季節・時間帯の漢字語も残す。

        usort( $terms, function( $a, $b ) {

            return AIAP_Compat::mb_strlen( $b ) <=> AIAP_Compat::mb_strlen( $a );

        } );



        return array_slice( $terms, 0, 16 );

    }



    /**

     * 再生成時に避ける共通要素を抽出する。

     */

    private static function extract_common_retry_keywords( $new_text, $old_text ) {

        $new_words = self::extract_keywords( $new_text );

        $old_words = self::extract_keywords( $old_text );

        if ( empty( $new_words ) || empty( $old_words ) ) {

            return array();

        }



        $common = array_values( array_unique( array_intersect( $new_words, $old_words ) ) );

        $common = array_filter( $common, function( $word ) {

            $word = trim( (string) $word );

            if ( $word === '' ) {

                return false;

            }

            // 1文字の一般語は避ける。

            return AIAP_Compat::mb_strlen( $word ) >= 2;

        } );



        // 長めの語を優先し、ノイズを減らす。

        usort( $common, function( $a, $b ) {

            return AIAP_Compat::mb_strlen( $b ) <=> AIAP_Compat::mb_strlen( $a );

        } );



        return array_slice( array_values( $common ), 0, 12 );

    }

}

