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

/**
 * AI API のエラー処理 / 分類 / リトライ判定 / シークレットマスキング
 *
 * 元 includes/class-aiap-ai-client.php から分離。
 * すべて static で、相互に self:: で呼び合うだけの独立した責務群。
 */
class AIAP_AI_Errors {

    /**
     * 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( 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( 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( 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 ) {
        $code = (int) $code;
        // Bug fix (audit r9 self-audit): Anthropic Claude は過負荷時に固有の
        // 529 Overloaded を返す。HTTP の標準コードではないため標準 5xx ハンドラの
        // リストに無く、これまで「リトライしない一般エラー」扱いで 1 回で諦めていた。
        // 加えて Cloudflare がオリジンと通信できない時に返す 520〜527 系も一時的な
        // エッジ起因のエラーで、AIAP が叩く API (api.anthropic.com / api.openai.com /
        // api.mistral.ai / openrouter.ai 等は全て Cloudflare 経由) で発生しうる。
        // これらも retryable に含める。
        return in_array( $code, array(
            408, 429,
            500, 502, 503, 504,
            520, 521, 522, 523, 524, 525, 526, 527, // Cloudflare edge errors
            529,                                     // Anthropic Overloaded
        ), 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' ),
            );
        }
        if ( in_array( $code, array(
            408,
            500, 502, 503, 504,
            520, 521, 522, 523, 524, 525, 526, 527, // Cloudflare edge errors
            529,                                     // Anthropic Overloaded
        ), 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,
        ) );
    }

}
