<?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,
    );

    /**
     * 設定された感度に応じた本文しきい値を取得
     */
    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() {
        $args = array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'posts_per_page' => 50,
            'date_query'     => array(
                array(
                    'after' => '1 week ago',
                ),
            ),
            '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' => 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',
        );
    }

    /**
     * Step1: PHP簡易チェック（キーワード重複率）
     */
    private static function check_keywords( $new_title, $new_content, $recent ) {
        $new_words = self::extract_keywords( $new_title . ' ' . 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 ( mb_strtolower( $new_title ) === mb_strtolower( $post['title'] ) ) {
                return array(
                    'is_similar' => true,
                    /* translators: %s = matched post title */
                    'reason'     => sprintf( __( 'Title exact match: %s', 'ai-auto-poster' ), 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 ( 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,
                            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,
                        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 = 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 = 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スコアが高く出やすいため、本文抜粋も渡して具体的に比較させる。
        $past_list = '';
        foreach ( $recent 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 = mb_substr( $excerpt, 0, 160 );
            $past_list .= "{$num}. Title: {$title}
Excerpt: {$excerpt}
";
        }

        $new_excerpt = 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_Client::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 が該当。
     *
     * @param array|WP_Error $response   wp_remote_post の戻り値
     * @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( $response, $error_code, $label ) {
        if ( is_wp_error( $response ) ) {
            return $response;
        }
        $code = wp_remote_retrieve_response_code( $response );
        if ( (int) $code !== 200 ) {
            return AIAP_AI_Client::build_api_error_wp_error( $error_code, $label, $response );
        }
        return json_decode( wp_remote_retrieve_body( $response ), true );
    }

    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' ) );
        }
        $response = wp_remote_post( 'https://api.anthropic.com/v1/messages', array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type'      => 'application/json',
                'x-api-key'         => $settings['claude_api_key'],
                'anthropic-version' => '2023-06-01',
            ),
            'body' => wp_json_encode( array(
                'model'      => $settings['claude_model'],
                'max_tokens' => 150,
                'messages'   => array(
                    array( 'role' => 'user',      'content' => $prompt ),
                    // assistant プレフィル: 応答を必ず '{' から始めさせ、思考過程の混入を防ぐ。
                    // 戻り値先頭で '{' を補完する。
                    array( 'role' => 'assistant', 'content' => '{' ),
                ),
            ) ),
        ) );
        $data = self::decode_mini_response( $response, 'claude_error', 'Claude' );
        if ( is_wp_error( $data ) ) { return $data; }
        $body_text = isset( $data['content'][0]['text'] ) ? (string) $data['content'][0]['text'] : '';
        // プレフィルで送った '{' は応答に含まれないため、先頭に補完する。
        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_Client::get_model_capabilities() の戻り値
     * @return array OpenAI 互換 /chat/completions の body
     */
    private static function build_openai_chat_body( $model, $prompt, $caps ) {
        $body = array(
            'model'    => $model,
            'messages' => array( array( 'role' => 'user', 'content' => $prompt ) ),
            // JSON 出力強制 (build_ai_check_prompt の指示文に 'JSON' の語が含まれているので
            // OpenAI の "messages must contain the word 'json'" 制約も満たす。OpenRouter の
            // 一部モデルは非対応で 400 になる場合あり)
            'response_format' => array( 'type' => 'json_object' ),
        );
        if ( ! empty( $caps['token_param'] ) ) {
            // reasoning 系 (gpt-5 / o1 / o3 / o4) は思考トークンも上限から消費するため、
            // 150 では空応答になる。十分な上限を確保する。
            $body[ $caps['token_param'] ] = ( $caps['family'] === 'openai-reasoning' ) ? 2000 : 150;
        }
        return $body;
    }

    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_Client::get_model_capabilities( 'openai', $model );
        $body  = self::build_openai_chat_body( $model, $prompt, $caps );
        $response = wp_remote_post( 'https://api.openai.com/v1/chat/completions', array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $settings['openai_api_key'],
            ),
            'body' => wp_json_encode( $body ),
        ) );
        $data = self::decode_mini_response( $response, 'openai_error', 'OpenAI' );
        if ( is_wp_error( $data ) ) { return $data; }
        return isset( $data['choices'][0]['message']['content'] ) ? (string) $data['choices'][0]['message']['content'] : '';
    }

    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'] );
        $response = wp_remote_post( $url, array(
            'timeout' => 30,
            'headers' => array( 'Content-Type' => 'application/json' ),
            'body'    => wp_json_encode( 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,
                ),
            ) ),
        ) );
        $data = self::decode_mini_response( $response, 'gemini_error', 'Gemini' );
        if ( is_wp_error( $data ) ) { return $data; }
        return isset( $data['candidates'][0]['content']['parts'][0]['text'] ) ? (string) $data['candidates'][0]['content']['parts'][0]['text'] : '';
    }

    private static function call_openai_compatible_mini( $settings, $prompt ) {
        // Mistral / Groq / DeepSeek / OpenRouter はOpenAI互換API
        $endpoints = array(
            'mistral'    => array( 'url' => 'https://api.mistral.ai/v1/chat/completions',  'key' => 'mistral_api_key',    'model' => 'mistral_model' ),
            'groq'       => array( 'url' => 'https://api.groq.com/openai/v1/chat/completions', 'key' => 'groq_api_key', 'model' => 'groq_model' ),
            'deepseek'   => array( 'url' => 'https://api.deepseek.com/chat/completions',   'key' => 'deepseek_api_key',   'model' => 'deepseek_model' ),
            'openrouter' => array( 'url' => 'https://openrouter.ai/api/v1/chat/completions', 'key' => 'openrouter_api_key', 'model' => 'openrouter_model' ),
        );

        $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_Client::get_model_capabilities( $provider, $model );
        $body = self::build_openai_chat_body( $model, $prompt, $caps );

        $response = wp_remote_post( $ep['url'], array(
            'timeout' => 30,
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . $key,
            ),
            'body' => wp_json_encode( $body ),
        ) );
        $data = self::decode_mini_response( $response, $provider . '_error', ucfirst( $provider ) );
        if ( is_wp_error( $data ) ) { return $data; }
        return isset( $data['choices'][0]['message']['content'] ) ? (string) $data['choices'][0]['message']['content'] : '';
    }

    /**
     * 再生成用のプロンプト修正
     * 過去記事との類似を回避する指示を追加
     */
    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 !== '' ? 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 !== '' ? 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 . ' ' . 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 mb_strlen( $b ) <=> 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 mb_strlen( $word ) >= 2;
        } );

        // 長めの語を優先し、ノイズを減らす。
        usort( $common, function( $a, $b ) {
            return mb_strlen( $b ) <=> mb_strlen( $a );
        } );

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