<?php

if ( ! defined( 'ABSPATH' ) ) exit;



/**

 * AI API のエラー処理 / 分類 / リトライ判定 / シークレットマスキング

 *

 * 元 includes/class-aiap-ai-client.php から分離。

 * すべて static で、相互に self:: で呼び合うだけの独立した責務群。

 */

class AIAP_AI_Errors {



    /**

     * リトライ対象として扱う HTTP ステータスコード集合 (single source of truth)。

     *

     * 旧実装は is_retryable_http_code と classify_api_error の server_error 分岐の

     * 双方で同じコード集合を独立に列挙していた (CLAUDE.md「同一コード・重複処理」)。

     * 片方を変更すると他方とズレるリスクがあったため定数に集約する。

     *

     * - 408: Request Timeout

     * - 429: Too Many Requests

     * - 500/502/503/504: 標準 5xx

     * - 520-527: Cloudflare edge errors (api.anthropic.com / api.openai.com など Cloudflare 経由)

     * - 529: Anthropic Overloaded (Claude 固有)

     */

    const RETRYABLE_HTTP_CODES = array(

        408, 429,

        500, 502, 503, 504,

        520, 521, 522, 523, 524, 525, 526, 527,

        529,

    );



    /**

     * 「API キー未設定」エラーを統一形式で返す

     *

     * 旧実装は AI/画像/dedup の各プロバイダ呼び出し冒頭 16+ 箇所で

     * `return new WP_Error( 'no_api_key', __( 'XXX API key is not set.' ) );` の

     * パターンを独立に書いていた (CLAUDE.md「同一コード・重複処理が存在する場合は試験結果NG」)。

     *

     * @param string $provider_label プロバイダ表示名 (例: 'OpenAI'、'Claude'、'Stability AI')。

     *                               空文字なら汎用メッセージ。

     * @return WP_Error

     */

    public static function no_api_key_error( $provider_label = '' ) {

        $provider_label = (string) $provider_label;

        if ( $provider_label === '' ) {

            $msg = __( 'API key is not set.', 'ai-auto-poster' );

        } else {

            /* translators: %s = provider name (e.g. "OpenAI") */

            $msg = sprintf( __( '%s API key is not set.', 'ai-auto-poster' ), $provider_label );

        }

        return new WP_Error( 'no_api_key', $msg );

    }



    /**

     * API エラーレスポンスから短く読める1行メッセージを作る

     * - JSONの error.message が取れればそれを使用

     * - 取れない場合(HTML返却など)は HTTP ステータス + <title> or 先頭を抽出して短縮

     *

     * @param int    $code  HTTPステータスコード

     * @param string $body  レスポンスボディ(raw)

     * @param array  $data  json_decodeの結果(取れていれば)

     * @return string 整形済みエラー文字列

     */

    public static function format_api_error_message( $code, $body, $data ) {

        // 1) JSONから message が取れる場合

        // OpenAI/Anthropic/Gemini 系: error.message

        // Stability/Pixabay/Pexels 系: 直接 message / detail / errors[0].message

        if ( is_array( $data ) ) {

            $candidate = '';

            if ( isset( $data['error']['message'] ) ) {

                $candidate = (string) $data['error']['message'];

            } elseif ( isset( $data['message'] ) && is_string( $data['message'] ) ) {

                $candidate = (string) $data['message'];

            } elseif ( isset( $data['detail'] ) && is_string( $data['detail'] ) ) {

                $candidate = (string) $data['detail'];

            } elseif ( isset( $data['errors'][0]['message'] ) ) {

                $candidate = (string) $data['errors'][0]['message'];

            } elseif ( isset( $data['errors'] ) && is_array( $data['errors'] ) ) {

                // Stability のように errors が文字列配列の場合

                $first = reset( $data['errors'] );

                if ( is_string( $first ) ) {

                    $candidate = $first;

                }

            }

            if ( $candidate !== '' ) {

                return sprintf( /* translators: 1: HTTP status code, 2: error detail from the provider */ __( 'HTTP %d: %s', 'ai-auto-poster' ), $code, self::scrub_secrets( $candidate ) );

            }

        }

        // 2) HTML (502/503/504 Cloudflareエラーなど) → <title> を抽出

        if ( is_string( $body ) && stripos( ltrim( $body ), '<' ) === 0 ) {

            if ( preg_match( '/<title>\s*(.*?)\s*<\/title>/is', $body, $m ) ) {

                $title = trim( wp_strip_all_tags( $m[1] ) );

                if ( $title !== '' ) {

                    return sprintf( /* translators: 1: HTTP status code, 2: error detail from the provider */ __( 'HTTP %d: %s', 'ai-auto-poster' ), $code, self::scrub_secrets( AIAP_Compat::mb_substr( $title, 0, 120 ) ) );

                }

            }

            // titleも無ければ HTMLタグ除去して先頭120字

            $text = trim( wp_strip_all_tags( $body ) );

            $text = preg_replace( '/\s+/', ' ', $text );

            if ( $text !== '' ) {

                return sprintf( /* translators: 1: HTTP status code, 2: error detail from the provider */ __( 'HTTP %d: %s', 'ai-auto-poster' ), $code, self::scrub_secrets( AIAP_Compat::mb_substr( $text, 0, 120 ) ) );

            }

            return sprintf(

                /* translators: %d = HTTP status code */

                __( 'HTTP %d (upstream error)', 'ai-auto-poster' ),

                $code

            );

        }

        // 3) プレーンテキスト → 先頭120字

        $text = is_string( $body ) ? trim( $body ) : '';

        if ( $text !== '' ) {

            return sprintf( /* translators: 1: HTTP status code, 2: error detail from the provider */ __( 'HTTP %d: %s', 'ai-auto-poster' ), $code, self::scrub_secrets( AIAP_Compat::mb_substr( $text, 0, 120 ) ) );

        }

        return sprintf( /* translators: %d = HTTP status code */ __( 'HTTP %d', 'ai-auto-poster' ), $code );

    }



    /**

     * エラーメッセージ等に紛れ込んだ可能性のある API キー/トークンをマスクする

     *

     * 通常は API 側がレスポンスにキーをエコーすることはないが、誤実装プロキシや

     * デバッグメッセージ経由で混入する可能性に備える防御層。ログや UI 表示前に

     * 必ず通すこと。

     *

     * @param string $text サニタイズ対象文字列

     * @return string マスク後の文字列

     */

    public static function scrub_secrets( $text ) {

        if ( ! is_string( $text ) || $text === '' ) {

            return '';

        }

        // Bearer トークン: "Bearer sk-...xxx" → "Bearer ***"

        $text = preg_replace( '/Bearer\s+[A-Za-z0-9_\-\.]{8,}/i', 'Bearer ***', $text );

        // sk-/sk_/pk_ で始まる長めの英数字トークン (OpenAI / Anthropic / Stripe 系)

        $text = preg_replace( '/\b(sk|pk)[\-_][A-Za-z0-9_\-]{16,}/', '$1-***', $text );

        // x-api-key ヘッダ風の `key=...` `api_key=...`

        $text = preg_replace( '/(api[_-]?key\s*[:=]\s*)["\']?[A-Za-z0-9_\-]{8,}["\']?/i', '$1***', $text );

        return $text;

    }



    /**

     * HTTP エラーがリトライすべき種別か判定する。

     *

     * - 429 (Rate Limit): リトライ可能

     * - 408 / 500 / 502 / 503 / 504: 一時的サーバ側エラー、リトライ可能

     * - 401 / 403: 認証/権限エラー、リトライ無意味

     * - 400 / 404 / 422: 入力エラー、リトライ無意味

     * - その他: 安全側に倒してリトライ不可

     *

     * @param int $code HTTP ステータスコード

     * @return bool

     */

    public static function is_retryable_http_code( $code ) {

        return in_array( (int) $code, self::RETRYABLE_HTTP_CODES, true );

    }



    /**

     * リトライ間の待機秒数を計算する単一の真実の供給源 (single source of truth)。

     *

     * Bug fix (audit r9 self-audit): 旧実装は同じ指数バックオフ + jitter ロジックが

     *   1. AI_Client::generate_with_retry の sleep (cron 経路の AI 生成)

     *   2. Image_Client::call_with_retry の sleep (画像生成、cron + UI 両方)

     *   3. assets/admin.js の runAiLoop の setTimeout (UI 経路の AI 生成)

     * の 3 箇所に重複して書かれていた。バックオフ調整のたびに 3 箇所同期する必要があり

     * DRY 違反だった。本ヘルパーに集約し、PHP の retry 機構はここを呼び、

     * UI (admin.js) は ajax レスポンスに含まれる next_sleep_seconds の値を使う。

     *

     * 待機戦略:

     *   1. API が Retry-After ヘッダで指示してきたらそれを尊重 (上限 60 秒)

     *      - Anthropic 529 / OpenAI 429 等で値が来る

     *   2. なければ指数バックオフ: 2^attempt 秒 (1→2, 2→4, 3→8)

     *   3. 0~1 秒のランダム jitter を加算

     *      - thundering herd (複数プラグインが同タイミングで API 叩く事故) を回避

     *   4. 最低 2 秒、最大 30 秒で clamp

     *      - 1 リクエスト内で過剰な sleep を避ける一方、過負荷サーバへの追い打ちも防ぐ

     *

     * 旧線形バックオフ ($attempt 秒 = 1+2+3=6 秒) では Anthropic 529 のような

     * 数十秒の過負荷状態に追いつかず、3 回とも同じ過負荷ウィンドウに当たって全敗する

     * パターンになっていた。

     *

     * @param int $attempt     何回目のリトライか (1, 2, 3, ...)

     * @param int $retry_after API レスポンスの Retry-After ヘッダの値 (秒)。0 なら無視。

     * @return int 待機秒数

     */

    public static function compute_retry_sleep_seconds( $attempt, $retry_after = 0 ) {

        $attempt     = max( 1, (int) $attempt );

        $retry_after = max( 0, (int) $retry_after );



        if ( $retry_after > 0 ) {

            return min( 60, $retry_after );

        }



        $base_sleep = (int) pow( 2, $attempt );           // 2, 4, 8, 16, ...

        $jitter     = mt_rand( 0, 1000 ) / 1000.0;        // 0.000-1.000

        $sleep      = (int) ceil( $base_sleep + $jitter );

        return max( 2, min( 30, $sleep ) );

    }



    /**

     * 429 のうち、リトライしても解消しない「課金/クォータ枯渇」系エラーかを判定する。

     *

     * Gemini の "exceeded its monthly spending cap" / OpenAI の "exceeded current quota" /

     * Anthropic の "credit balance is too low" 等は、待っても直らずユーザーが管理画面で

     * 上限引き上げ・残高追加するまで継続する。

     *

     * @param string $message API レスポンス由来のエラーメッセージ

     * @return bool true = 永続エラー(リトライ不可)

     */

    public static function is_quota_exhausted_error( $message ) {

        if ( ! is_string( $message ) || $message === '' ) {

            return false;

        }

        $needles = array(

            'spending cap',

            'spend cap',

            'exceeded its monthly',

            'exceeded your current quota',

            'insufficient_quota',

            'billing_hard_limit_reached',

            'credit balance is too low',

            'plan_does_not_allow',

            'quota exceeded',

        );

        foreach ( $needles as $n ) {

            if ( stripos( $message, $n ) !== false ) {

                return true;

            }

        }

        return false;

    }



    /**

     * API エラー応答を種別判定し、リトライ可否とユーザー向けヒントを返す。

     *

     * 戻り値は array( 'kind' => string, 'retryable' => bool, 'hint' => string )。

     * `hint` は __() でラップされた翻訳済み文字列(空文字なら hint なし)。

     *

     * 判定順:

     *   1. quota_exhausted (message 内容で判定。429 を rate_limit に取られる前に評価する)

     *   2. auth_error (401 / 403)

     *   3. bad_request (400 / 404 / 422)

     *   4. rate_limit (429)

     *   5. server_error (408 / 500 / 502 / 503 / 504)

     *   6. unknown (上記いずれにも該当しない)

     *

     * @param int    $code    HTTP ステータスコード

     * @param string $message format_api_error_message() で生成済みのエラーメッセージ

     * @return array

     */

    public static function classify_api_error( $code, $message ) {

        $code    = (int) $code;

        $message = is_string( $message ) ? $message : '';



        // Bug fix (audit r9 self-audit): 旧 hint は 'Aborted after retries...' のように

        // 「最終結果」を述べる文章だった。しかし build_api_error_wp_error は

        // 個別 API 呼び出しが 1 回失敗するたびに毎回この hint を WP_Error に詰めるため、

        // リトライ 1 回目失敗時点のログにも「リトライ後に中止しました」と表示されて

        // しまい「まだリトライ中なのに中止表記」という自己矛盾が起きていた。

        // hint を「状態の説明」に書き換え、「中止」「Abort」は call_with_retry /

        // generate_with_retry が最終的に retry を諦めた段階で別途被せる方針に変更する。

        if ( self::is_quota_exhausted_error( $message ) ) {

            return array(

                'kind'      => 'quota_exhausted',

                'retryable' => false,

                /* translators: %s: provider label (e.g. "Claude", "OpenAI", "Gemini") */

                'hint'      => __( '%s: API spend cap or quota exceeded.', 'ai-auto-poster' ),

            );

        }

        if ( $code === 401 || $code === 403 ) {

            return array(

                'kind'      => 'auth_error',

                'retryable' => false,

                /* translators: %s: provider label */

                'hint'      => __( '%s: API authentication or permission error.', 'ai-auto-poster' ),

            );

        }

        if ( $code === 400 || $code === 404 || $code === 422 ) {

            return array(

                'kind'      => 'bad_request',

                'retryable' => false,

                /* translators: %s: provider label */

                'hint'      => __( '%s: Invalid request parameters.', 'ai-auto-poster' ),

            );

        }

        if ( $code === 429 ) {

            return array(

                'kind'      => 'rate_limit',

                'retryable' => true,

                /* translators: %s: provider label */

                'hint'      => __( '%s: Rate limit exceeded.', 'ai-auto-poster' ),

            );

        }

        // 429 は上で rate_limit として処理済みなので、ここでは 429 を除いた retryable コードを判定する。

        if ( $code !== 429 && in_array( $code, self::RETRYABLE_HTTP_CODES, true ) ) {

            return array(

                'kind'      => 'server_error',

                'retryable' => true,

                /* translators: %s: provider label */

                'hint'      => __( '%s: Provider server error.', 'ai-auto-poster' ),

            );

        }

        return array(

            'kind'      => 'unknown',

            'retryable' => false,

            'hint'      => '',

        );

    }



    /**

     * WP_Error からログ向けメッセージを取り出す。

     *

     * UI 表示用メッセージ(短い日本語ヒント)に加え、build_api_error_wp_error が

     * error_data に格納した raw_message(英語の生エラー本文)があれば併記する。

     * 実行ログタブで原因を切り分けられるようにするためのヘルパー。

     *

     * @param WP_Error $err

     * @return string ログ向けメッセージ(UI ヒント + " | " + raw)

     */

    public static function format_error_for_log( $err ) {

        if ( ! is_wp_error( $err ) ) {

            return '';

        }

        $msg  = (string) $err->get_error_message();

        $data = $err->get_error_data();

        if ( is_array( $data ) && ! empty( $data['raw_message'] ) && is_string( $data['raw_message'] ) ) {

            $raw = $data['raw_message'];

            if ( $raw !== '' && stripos( $msg, $raw ) === false ) {

                return $msg . ' | ' . $raw;

            }

        }

        return $msg;

    }



    /**

     * `wp_remote_*` レスポンスから API エラー用の WP_Error を組み立てる

     *

     * data 配列に `http_code` と `retry_after` を含めることで、上位の

     * generate_with_retry がリトライ可否と待機時間を判断できる。

     *

     * @param string $prefix エラーコード接頭辞("claude_error" 等)

     * @param string $label  エラーメッセージの先頭ラベル("Claude" 等)

     * @param array  $response wp_remote_post の戻り値

     * @return WP_Error

     */

    public static function build_api_error_wp_error( $prefix, $label, $response ) {

        $code = (int) wp_remote_retrieve_response_code( $response );

        $body = (string) wp_remote_retrieve_body( $response );

        $data = json_decode( $body, true );

        $msg  = self::format_api_error_message( $code, $body, $data );

        // Retry-After ヘッダ(秒数 または HTTP-Date)。秒数のみ尊重し、

        // 数値以外(date 形式)は単純に既定の待機時間にフォールバックする。

        $retry_after_raw = wp_remote_retrieve_header( $response, 'retry-after' );

        $retry_after = is_numeric( $retry_after_raw ) ? max( 0, (int) $retry_after_raw ) : 0;



        // エラー種別を判定し、UI 表示メッセージは短いヒントのみにする。

        // 英語の生メッセージ($msg)は raw_message として error_data に保持し、実行ログに記録する。

        // unknown 種別(分類不能)時のみ生メッセージを画面に出す(他に手がかりがないため)。

        // i18n: hint はプロバイダ名($label)を %s で受け取る完結した文として翻訳する。

        // 文字列を連結するのではなく sprintf で組み立てることで、語順が異なる言語にも対応できる。

        $classified = self::classify_api_error( $code, $msg );

        if ( $classified['hint'] !== '' ) {

            $display_msg = sprintf( $classified['hint'], $label );

        } else {

            /* translators: 1: provider label (e.g. "Claude"), 2: raw API error message */

            $display_msg = sprintf( __( '%1$s: %2$s', 'ai-auto-poster' ), $label, $msg );

        }



        return new WP_Error( $prefix, $display_msg, array(

            'http_code'   => $code,

            'retry_after' => $retry_after,

            'retryable'   => $classified['retryable'],

            'kind'        => $classified['kind'],

            'raw_message' => $msg,

        ) );

    }



}

