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

/**
 * 実行ログを管理するクラス
 *
 * 新形式: メッセージは i18n キーとパラメータで保存し、表示時に展開する
 *   → 言語切替でログ表示も即時切り替わる
 *
 * 互換: 旧形式(展開済み文字列をそのまま保存)も読み出せる
 */
class AIAP_Logger {

    const OPTION_KEY = 'aiap_logs';
    const RETENTION_DAYS = 7;

    /**
     * ログを追加
     *
     * 呼び出し方:
     *   AIAP_Logger::add( 'info', 'log_auto_run_started', array('Fri', '18:00', 'タスク', 0) )
     *     → DB に { message_key, params } を保存(推奨・新形式)
     *
     *   AIAP_Logger::add( 'info', '生メッセージ', array(...) )
     *     → DB に { message } として保存(後方互換・非推奨)
     *
     * 第2引数が i18n に存在するキーなら新形式、それ以外は生メッセージとみなす。
     *
     * @param string $status        success | error | info
     * @param string $key_or_msg    i18n キー(推奨) or 生メッセージ
     * @param array  $params_or_ctx 新形式: sprintf 用パラメータ / 旧形式: コンテキスト
     */
    public static function add( $status, $key_or_msg, $params_or_ctx = array(), $extra_context = array() ) {
        // 並列書き込みでログが消失しないよう短いロックを取得する。
        // 取得失敗時もログは諦めずに進める(ログ機能は副次的なもののため)。
        // Bug fix (audit r9): release はトークンを渡してレース時の他者ロック誤削除を防ぐ。
        $lock_token = false;
        if ( class_exists( 'AIAP_Lock' ) ) {
            $lock_token = AIAP_Lock::acquire_with_retry( 'logger', 5, 5, 10000 );
        }

        try {
            $logs = get_option( self::OPTION_KEY, array() );

            $entry = array(
                'time'   => current_time( 'mysql' ),
                'status' => $status,
            );

            // 第2引数が i18n キーとして登録されていれば新形式で保存
            if ( self::is_i18n_key( $key_or_msg ) ) {
                $entry['message_key'] = $key_or_msg;
                $params               = is_array( $params_or_ctx ) ? array_values( $params_or_ctx ) : array();
                $entry['params']      = self::sanitize_params( $params );
                // 第4引数(任意): rawレスポンス等のデバッグ用付加情報を保存
                if ( is_array( $extra_context ) && ! empty( $extra_context ) ) {
                    $entry['context'] = $extra_context;
                }
            } else {
                // 旧形式: 既に展開された文字列をそのまま保存
                $entry['message'] = self::sanitize_string( (string) $key_or_msg );
                if ( is_array( $params_or_ctx ) && ! empty( $params_or_ctx ) ) {
                    $entry['context'] = $params_or_ctx;
                }
            }

            array_unshift( $logs, $entry );

            $logs = self::prune_old_logs( $logs );

            // autoload=false: ログは管理画面でしか参照されないため、
            // 全リクエストで wp_options から自動ロードする必要はない。
            update_option( self::OPTION_KEY, $logs, false );
        } finally {
            if ( $lock_token && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( 'logger', $lock_token );
            }
        }
    }

    /**
     * ログメッセージキー → 英語ソース文字列のマップ
     *
     * Logger は「キー保存」「表示時に __() で翻訳」の二段構え。
     * このマップによりキーから英語ソースを引き、__('English', 'ai-auto-poster')
     * で翻訳文字列に変換する。WordPress.org の翻訳ツールが解析できる形になる。
     */
    private static function get_message_source( $key ) {
        // 英語ソースは ai-auto-poster ドメインで翻訳される実文字列でなければならない
        // (静的呼び出しのため、コード上で必ず __( '...', 'ai-auto-poster' ) として登場している)
        switch ( $key ) {
            // log_*
            /* translators: 1: source, 2: current retry, 3: max retries, 4: error message, 5: task name */
            case 'log_server_error_retry':      return __( '%1$s: recovering from server error %2$d/%3$d - %4$s (task: %5$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: total attempts, 3: error message, 4: task name */
            case 'log_server_error_failed':     return __( '%1$s: server error did not clear after %2$d attempts - %3$s (task: %4$s)', 'ai-auto-poster' );
            /* translators: 1: recover max count, 2: dropped count, 3: dropped slot details */
            /* translators: 1: enqueued count, 2: enqueued slot details */
            case 'log_auto_enqueued':           return __( 'Auto: enqueued %1$d missed slot(s) for later recovery: %2$s', 'ai-auto-poster' );
            /* translators: 1: recover max count, 2: dropped count, 3: dropped slot details */
            case 'log_auto_dropped_old':        return __( 'Auto run: dropped %2$d oldest missed slot(s) (recover max=%1$d): %3$s', 'ai-auto-poster' );
            /* translators: 1: task ID, 2: scheduled time */
            case 'log_auto_queue_task_missing': return __( 'Auto run: queued task not found, skipped (id:%1$s, time:%2$s)', 'ai-auto-poster' );
            /* translators: 1: dropped count, 2: dropped slot details */
            case 'log_auto_recover_off':        return __( 'Auto run: recover disabled, dropped %1$d missed slot(s): %2$s', 'ai-auto-poster' );
            /* translators: 1: minutes late, 2: weekday, 3: time, 4: task name, 5: queue remaining */
            case 'log_auto_recovered_late':     return __( 'Auto run (recovered, %1$d min late): %2$s %3$s (task: %4$s, queue remaining: %5$d)', 'ai-auto-poster' );
            /* translators: 1: weekday, 2: time, 3: task name, 4: queue remaining */
            case 'log_auto_run_started':        return __( 'Auto run start: %1$s %2$s (task: %3$s, queue remaining: %4$d)', 'ai-auto-poster' );
            case 'log_crypto_decrypt_failed':   return __( 'Failed to decrypt API key. AUTH_KEY may have changed (e.g., WP migration). Please re-enter the API key in settings.', 'ai-auto-poster' );
            case 'log_crypto_encrypt_failed':   return __( 'Failed to encrypt API key (AUTH_KEY missing or openssl unavailable). API key was NOT saved.', 'ai-auto-poster' );
            /* translators: 1: source, 2: attempt count, 3: detection method */
            case 'log_dedup_passed':            return __( '%1$s: dedup passed on attempt %2$d (method: %3$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: current retry, 3: max retries, 4: detection method, 5: similar post title */
            case 'log_dedup_retry':             return __( '%1$s: resolving similar content %2$d/%3$d (method: %4$s, similar: %5$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: max retries */
            case 'log_dedup_max_reached':       return __( '%1$s: similar content retry limit (%2$d) reached, posted last generation as is', 'ai-auto-poster' );
            /* translators: 1: source, 2: error message, 3: task name */
            case 'log_empty_content_with_task': return __( '%1$s: %2$s (task: %3$s)', 'ai-auto-poster' );
            case 'log_free_limit_reached_skip_cron': return __( 'Free plan monthly post limit reached (10/month). Cron skipped this run.', 'ai-auto-poster' );
            /* translators: 1: source (manual/auto), 2: current retry, 3: max retries, 4: error message */
            case 'log_image_retry':             return __( '%1$s: image — recovering from server error %2$d/%3$d - %4$s', 'ai-auto-poster' );
            /* translators: 1: saved model id, 2: fallback model id */
            case 'log_imagen_fallback':         return __( 'Imagen: saved model "%1$s" unavailable, fell back to "%2$s" for this run.', 'ai-auto-poster' );
            /* translators: 1: recover max count, 2: dropped count, 3: dropped slot details */
            case 'log_manual_dropped_old':      return __( 'Manual: dropped %2$d oldest missed slot(s) (recover max=%1$d): %3$s', 'ai-auto-poster' );
            /* translators: %s = task ID */
            case 'log_manual_empty_prompt':     return __( 'Manual: empty prompt, using next scheduled task [ID:%s]', 'ai-auto-poster' );
            /* translators: 1: enqueued count, 2: enqueued slot details */
            case 'log_manual_enqueued':         return __( 'Manual: enqueued %1$d missed slot(s) for later recovery: %2$s', 'ai-auto-poster' );
            /* translators: 1: dropped count, 2: dropped slot details */
            case 'log_manual_recover_off':      return __( 'Manual: recover disabled, dropped %1$d missed slot(s): %2$s', 'ai-auto-poster' );
            /* translators: %s = error message */
            case 'log_manual_fatal':            return __( 'Manual: %s', 'ai-auto-poster' );
            /* translators: 1: post ID, 2: post title */
            case 'log_manual_post_created':     return __( 'Manual: Post created [ID:%1$d] %2$s', 'ai-auto-poster' );
            /* translators: %s = error message */
            case 'log_manual_post_error':       return __( 'Manual: Post creation failed - %s', 'ai-auto-poster' );
            /* translators: 1: post ID, 2: post title, 3: RSS summary (mode/title or mode/count) */
            case 'log_manual_post_created_with_rss': return __( 'Manual: Post created [ID:%1$d] %2$s (RSS: %3$s)', 'ai-auto-poster' );
            /* translators: %s = today weekday name */
            case 'log_no_tasks_today':          return __( 'Auto: no tasks scheduled for today (%s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: post ID, 3: post title, 4: task name, 5: AI provider */
            case 'log_post_created':            return __( '%1$s: Post created [ID:%2$s] %3$s (task: %4$s, AI: %5$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: post ID, 3: post title, 4: task name, 5: AI provider, 6: RSS summary */
            case 'log_post_created_with_rss':   return __( '%1$s: Post created [ID:%2$s] %3$s (task: %4$s, AI: %5$s, RSS: %6$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: error message */
            case 'log_post_creation_failed':   return __( '%1$s: Post creation failed - %2$s', 'ai-auto-poster' );
            /* translators: 1: feed name */
            case 'log_rss_feed_added':          return __( 'RSS feed added: %1$s', 'ai-auto-poster' );
            /* translators: 1: feed name */
            case 'log_rss_feed_deleted':        return __( 'RSS feed deleted: %1$s', 'ai-auto-poster' );
            /* translators: 1: source, 2: task name */
            case 'log_rss_fallback_to_normal':  return __( '%1$s: no fresh RSS articles, fell back to normal generation (task: %2$s)', 'ai-auto-poster' );
            /* translators: 1: feed name, 2: error message */
            case 'log_rss_fetch_failed':        return __( 'RSS fetch failed: %1$s - %2$s', 'ai-auto-poster' );
            /* translators: 1: feed name, 2: purged guid count */
            case 'log_rss_history_purged':      return __( 'RSS history purged for %1$s (%2$d guids)', 'ai-auto-poster' );
            /* translators: 1: source, 2: returned index, 3: available count */
            case 'log_rss_pick_index_invalid':  return __( '%1$s: AI returned invalid selected_rss_index=%2$d (available=%3$d), using first article instead', 'ai-auto-poster' );
            /* translators: 1: source, 2: task name */
            case 'log_rss_skipped_free_plan':   return __( '%1$s: RSS is a Pro feature, skipped RSS phase (task: %2$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: task name */
            case 'log_rss_skipped_no_articles': return __( '%1$s: no fresh RSS articles, post skipped (task: %2$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: count, 3: mode, 4: task name */
            case 'log_rss_used_articles':       return __( '%1$s: used %2$d RSS article(s) in %3$s mode (task: %4$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: error message */
            case 'log_source_error':            return __( '%1$s: %2$s', 'ai-auto-poster' );
            /* translators: 1: source, 2: task name */
            case 'log_task_missing_language':   return __( '%1$s: task skipped — content language is not set (task: %2$s)', 'ai-auto-poster' );
            /* translators: 1: source, 2: post ID, 3: error message */
            case 'log_thumbnail_failed':        return __( '%1$s: featured image generation failed [Post ID:%2$s] - %3$s', 'ai-auto-poster' );
            // src_*
            case 'src_auto':                    return __( 'Auto', 'ai-auto-poster' );
            case 'src_manual':                  return __( 'Manual', 'ai-auto-poster' );
        }
        return null;
    }

    /**
     * params 配列内の文字列値から制御文字 (改行/タブ/NUL等) を空白に置換する。
     * AI 生成タイトル等に改行が混じってもログ判読性を保つため。
     */
    private static function sanitize_params( $params ) {
        if ( ! is_array( $params ) ) {
            return array();
        }
        foreach ( $params as $i => $v ) {
            if ( is_string( $v ) ) {
                $params[ $i ] = self::sanitize_string( $v );
            }
        }
        return $params;
    }

    /**
     * 文字列から制御文字を取り除く。\t は半角スペースに、その他制御文字は除去。
     */
    private static function sanitize_string( $value ) {
        // \x00-\x08, \x0B, \x0C, \x0E-\x1F, \x7F を半角スペースに置換 (\t/\n/\r も含めて空白化)
        $value = preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/u', ' ', $value );
        // 連続改行はスペース1つに圧縮
        $value = preg_replace( '/[\r\n]+/u', ' ', (string) $value );
        return $value === null ? '' : $value;
    }

    /**
     * 指定された値が i18n の登録キーかどうか判定
     */
    public static function is_i18n_key( $candidate ) {
        // 文字列以外、空文字、スペース・特殊文字を含むものはキーではない(=生メッセージ)
        if ( ! is_string( $candidate ) || $candidate === '' ) {
            return false;
        }
        // 識別子っぽくないなら生メッセージ
        if ( ! preg_match( '/^[a-z][a-z0-9_]*$/i', $candidate ) ) {
            return false;
        }
        // get_message_source で取得できれば登録キー
        return self::get_message_source( $candidate ) !== null;
    }

    /**
     * ログのメッセージ部を表示用文字列に変換
     *
     * パラメータが i18n キー (例: 'src_manual', 'src_auto') の場合は
     * 表示時に再展開して、言語切替に追従させる。
     *
     * @param array $log
     * @return string
     */
    public static function format_message( $log ) {
        // 新形式: キー+パラメータ → __() で展開
        if ( ! empty( $log['message_key'] ) ) {
            $template = self::get_message_source( $log['message_key'] );
            if ( $template === null ) {
                // 未登録キーの場合はキーをそのまま返す(開発時の発見性重視)
                return $log['message_key'];
            }
            $params = isset( $log['params'] ) && is_array( $log['params'] ) ? $log['params'] : array();
            // パラメータの中に i18n キーが入っていたら再帰展開(ネストは1段だけ)
            foreach ( $params as $i => $p ) {
                if ( self::is_i18n_key( $p ) ) {
                    $params[ $i ] = self::get_message_source( $p );
                }
            }
            if ( empty( $params ) ) {
                return $template;
            }
            try {
                $expanded = vsprintf( $template, array_values( $params ) );
                if ( $expanded === false ) {
                    self::log_debug_format_mismatch( $log['message_key'], $template, $params );
                    return $template;
                }
                return $expanded;
            } catch ( \Throwable $e ) {
                self::log_debug_format_mismatch( $log['message_key'], $template, $params );
                return $template;
            }
        }

        // 旧形式(後方互換): DBに保存された生メッセージ
        $message = isset( $log['message'] ) ? (string) $log['message'] : '';

        // 旧形式は記録時の言語で固定保存されているため、
        // 表示時の UI 言語と異なれば、ラベル部分を翻訳する
        $locale = function_exists( 'determine_locale' ) ? determine_locale() : get_locale();
        $lang   = ( strpos( $locale, 'ja' ) === 0 ) ? 'ja' : 'en';
        $message = self::translate_legacy_message( $message, $lang );
        return $message;
    }

    /**
     * 旧形式ログメッセージのラベル部分を、表示言語に合わせて変換
     *
     * 過去に「手動実行: 投稿作成 ...」と日本語で記録されたログを、
     * 英語UIで「Manual: Post created ...」と表示するためのフォールバック。
     */
    private static function translate_legacy_message( $message, $lang ) {
        if ( $lang === 'en' ) {
            // 日本語 → 英語
            $patterns = array(
                // 英語の「Manual:」プレフィックス + 日本語本文 → 英語に変換
                '/^Manual: 空プロンプトのため次の予定タスクを使用 \[ID:(.+)\]$/u'              => 'Manual: empty prompt, using next scheduled task [ID:$1]',
                '/^Manual: 取りこぼし(\d+)件を復元キューに追加: (.+)$/u'                       => 'Manual: enqueued $1 missed slot(s) for later recovery: $2',
                '/^Manual: 復元上限\((\d+)件\)を超えた古い取りこぼし(\d+)件を破棄: (.+)$/u'    => 'Manual: dropped $2 oldest missed slot(s) (recover max=$1): $3',
                '/^Auto run \(取りこぼし復元, (\d+)分遅れ\): (.+) (\d{2}:\d{2}) \(タスク: (.+), キュー残: (\d+)件\)$/u'
                    => 'Auto run (recovered, $1 min late): $2 $3 (task: $4, queue remaining: $5)',
                '/^Auto run start: (.+) (\d{2}:\d{2}) \(タスク: (.+), キュー残: (\d+)件\)$/u'  => 'Auto run start: $1 $2 (task: $3, queue remaining: $4)',
                // 日本語完全形 → 英語
                '/^手動実行: 投稿作成 \[ID:(\d+)\]\s*(.*)$/u'                                  => 'Manual: Post created [ID:$1] $2',
                '/^手動実行: アイキャッチを設定 \[投稿ID:(\d+)\]/u'                              => 'Manual: Thumbnail set [Post ID:$1]',
                '/^手動実行: アイキャッチ生成に失敗 - (.+)$/u'                                  => 'Manual: Thumbnail failed - $1',
                '/^手動実行: AI生成に失敗 - (.+)$/u'                                            => 'Manual: AI generation failed - $1',
                '/^手動実行: 投稿作成に失敗 - (.+)$/u'                                          => 'Manual: Post creation failed - $1',
                '/^手動実行: 空プロンプトのため次の予定タスクを使用 \[ID:(.+)\]$/u'              => 'Manual: empty prompt, using next scheduled task [ID:$1]',
                '/^手動実行: 取りこぼし(\d+)件を復元キューに追加: (.+)$/u'                       => 'Manual: enqueued $1 missed slot(s) for later recovery: $2',
                '/^手動実行: 復元上限\((\d+)件\)を超えた古い取りこぼし(\d+)件を破棄: (.+)$/u'    => 'Manual: dropped $2 oldest missed slot(s) (recover max=$1): $3',
                '/^手動実行: (.+)$/u'                                                            => 'Manual: $1',
                '/^自動実行: 投稿作成 \[ID:(\d+)\]\s*(.*)$/u'                                    => 'Auto: Post created [ID:$1] $2',
                '/^自動実行: アイキャッチを設定 \[投稿ID:(\d+), ([^\]]+)\]/u'                    => 'Auto: Thumbnail set [Post ID:$1, $2]',
                '/^自動実行: アイキャッチ画像の生成に失敗 \[投稿ID:(\d+)\] - (.+)$/u'             => 'Auto: featured image generation failed [Post ID:$1] - $2',
                '/^自動実行: AI生成失敗\((\d+)\/(\d+)回目\) → リトライ - (.+) \(タスク: (.+)\)$/u' => 'Auto: AI generation failed ($1/$2), retrying - $3 (task: $4)',
                '/^自動実行: AI生成失敗（(\d+)回リトライ） - (.+) \(タスク: (.+)\)$/u'           => 'Auto: AI generation failed ($1 retries) - $2 (task: $3)',
                '/^自動実行: 類似回避成功（(\d+)回目で通過, 判定: (.+)）$/u'                     => 'Auto: dedup passed on attempt $1 (method: $2)',
                '/^自動実行: 類似検出（(\d+)\/(\d+)回目, (.+): (.+)）→ 再生成$/u'                => 'Auto: similar detected ($1/$2, $3: $4) -> regenerating',
                '/^自動実行: 投稿作成失敗 - (.+)$/u'                                              => 'Auto: Post creation failed - $1',
                '/^自動実行: 今日（(.+)）に実行するタスクがありません$/u'                         => 'Auto run: no tasks scheduled for today ($1)',
                '/^自動実行: 復元上限\((\d+)件\)を超えた古い取りこぼし(\d+)件を破棄: (.+)$/u'     => 'Auto run: dropped $2 oldest missed slot(s) (recover max=$1): $3',
                '/^自動実行: 取りこぼし復元OFFのため(\d+)件を破棄: (.+)$/u'                       => 'Auto run: recover disabled, dropped $1 missed slot(s): $2',
                '/^自動実行: キュー内のタスクが見つからずスキップ \(id:(.+), 時刻:(.+)\)$/u'      => 'Auto run: queued task not found, skipped (id:$1, time:$2)',
                '/^自動実行\(取りこぼし復元, (\d+)分遅れ\): (.+) (\d{2}:\d{2}) \(タスク: (.+), キュー残: (\d+)件\)$/u'
                    => 'Auto run (recovered, $1 min late): $2 $3 (task: $4, queue remaining: $5)',
                '/^自動実行開始: (.+) (\d{2}:\d{2}) \(タスク: (.+), キュー残: (\d+)件\)$/u'      => 'Auto run start: $1 $2 (task: $3, queue remaining: $4)',
                '/^自動実行開始: (.+) (\d{2}:\d{2}) \(タスク: (.+)\)$/u'                          => 'Auto run start: $1 $2 (task: $3)',
            );
            // 曜日も英語化
            $message = strtr( $message, array(
                '月曜日' => 'Mon', '火曜日' => 'Tue', '水曜日' => 'Wed', '木曜日' => 'Thu',
                '金曜日' => 'Fri', '土曜日' => 'Sat', '日曜日' => 'Sun',
            ) );
        } else {
            // 英語 → 日本語
            $patterns = array(
                // 「自動実行開始」+ 英語のtask/queue部 → 日本語完全化
                '/^自動実行開始: (.+) (\d{2}:\d{2}) \(task: (.+), queue remaining: (\d+)\)$/u'
                    => '自動実行開始: $1 $2 (タスク: $3, キュー残: $4件)',
                '/^自動実行\(取りこぼし復元, (\d+)分遅れ\): (.+) (\d{2}:\d{2}) \(task: (.+), queue remaining: (\d+)\)$/u'
                    => '自動実行(取りこぼし復元, $1分遅れ): $2 $3 (タスク: $4, キュー残: $5件)',
                // 「Auto: Post created ... (task: ..., AI: ...)」→ 「自動実行: 投稿作成 ... (タスク: ..., AI: ...)」
                '/^Auto: Post created \[ID:(\d+)\] (.+) \(task: (.+), AI: (.+)\)$/u'
                    => '自動実行: 投稿作成 [ID:$1] $2 (タスク: $3, AI: $4)',
                // 英語完全形 → 日本語
                '/^Manual: Post created \[ID:(\d+)\]\s*(.*)$/u'                  => '手動実行: 投稿作成 [ID:$1] $2',
                '/^Manual: Thumbnail set \[Post ID:(\d+)\]/u'                    => '手動実行: アイキャッチを設定 [投稿ID:$1]',
                '/^Manual: Thumbnail failed - (.+)$/u'                           => '手動実行: アイキャッチ生成に失敗 - $1',
                '/^Manual: AI generation failed - (.+)$/u'                       => '手動実行: AI生成に失敗 - $1',
                '/^Manual: Post creation failed - (.+)$/u'                       => '手動実行: 投稿作成に失敗 - $1',
                '/^Manual: empty prompt, using next scheduled task \[ID:(.+)\]$/u' => '手動実行: 空プロンプトのため次の予定タスクを使用 [ID:$1]',
                '/^Manual: enqueued (\d+) missed slot\(s\) for later recovery: (.+)$/u' => '手動実行: 取りこぼし$1件を復元キューに追加: $2',
                '/^Manual: dropped (\d+) oldest missed slot\(s\) \(recover max=(\d+)\): (.+)$/u' => '手動実行: 復元上限($2件)を超えた古い取りこぼし$1件を破棄: $3',
                '/^Manual: (.+)$/u'                                              => '手動実行: $1',
                '/^Auto: Post created \[ID:(\d+)\]\s*(.*)$/u'                    => '自動実行: 投稿作成 [ID:$1] $2',
                '/^Auto: Thumbnail set \[Post ID:(\d+), ([^\]]+)\]/u'            => '自動実行: アイキャッチを設定 [投稿ID:$1, $2]',
                '/^Auto: featured image generation failed \[Post ID:(\d+)\] - (.+)$/u' => '自動実行: アイキャッチ画像の生成に失敗 [投稿ID:$1] - $2',
                '/^Auto: AI generation failed \((\d+)\/(\d+)\), retrying - (.+) \(task: (.+)\)$/u' => '自動実行: AI生成失敗($1/$2回目) → リトライ - $3 (タスク: $4)',
                '/^Auto: AI generation failed \((\d+) retries\) - (.+) \(task: (.+)\)$/u' => '自動実行: AI生成失敗（$1回リトライ） - $2 (タスク: $3)',
                '/^Auto: dedup passed on attempt (\d+) \(method: (.+)\)$/u'      => '自動実行: 類似回避成功（$1回目で通過, 判定: $2）',
                '/^Auto: similar detected \((\d+)\/(\d+), (.+): (.+)\) -> regenerating$/u' => '自動実行: 類似検出（$1/$2回目, $3: $4）→ 再生成',
                '/^Auto: Post creation failed - (.+)$/u'                         => '自動実行: 投稿作成失敗 - $1',
                '/^Auto run: no tasks scheduled for today \((.+)\)$/u'            => '自動実行: 今日（$1）に実行するタスクがありません',
                '/^Auto run: dropped (\d+) oldest missed slot\(s\) \(recover max=(\d+)\): (.+)$/u' => '自動実行: 復元上限($2件)を超えた古い取りこぼし$1件を破棄: $3',
                '/^Auto run: recover disabled, dropped (\d+) missed slot\(s\): (.+)$/u' => '自動実行: 取りこぼし復元OFFのため$1件を破棄: $2',
                '/^Auto run: queued task not found, skipped \(id:(.+), time:(.+)\)$/u' => '自動実行: キュー内のタスクが見つからずスキップ (id:$1, 時刻:$2)',
                '/^Auto run \(recovered, (\d+) min late\): (.+) (\d{2}:\d{2}) \(task: (.+), queue remaining: (\d+)\)$/u'
                    => '自動実行(取りこぼし復元, $1分遅れ): $2 $3 (タスク: $4, キュー残: $5件)',
                '/^Auto run start: (.+) (\d{2}:\d{2}) \(task: (.+), queue remaining: (\d+)\)$/u'
                    => '自動実行開始: $1 $2 (タスク: $3, キュー残: $4件)',
                '/^Auto run start: (.+) (\d{2}:\d{2}) \(task: (.+)\)$/u'         => '自動実行開始: $1 $2 (タスク: $3)',
            );
            // 英語の曜日は日本語の漢字+曜日表記へ
            // (より正確に: dow_xxx を resolve_dynamic_key で逆引きしてもよいが、固定変換でも実用上問題なし)
        }

        foreach ( $patterns as $pat => $rep ) {
            $converted = preg_replace( $pat, $rep, $message );
            if ( $converted !== null && $converted !== $message ) {
                return $converted;
            }
        }
        return $message;
    }

    private static function prune_old_logs( $logs ) {
        if ( ! is_array( $logs ) ) {
            return array();
        }

        $threshold = time() - ( self::RETENTION_DAYS * DAY_IN_SECONDS );

        $logs = array_filter( $logs, function( $log ) use ( $threshold ) {
            if ( ! is_array( $log ) || empty( $log['time'] ) ) {
                return false;
            }
            // $log['time'] は current_time('mysql') で保存された WP ローカル時刻文字列。
            // PHPサーバーのTZでstrtotimeすると最大9時間ずれるため、wp_timezone() で正しく解釈する。
            $dt = date_create_immutable_from_format( 'Y-m-d H:i:s', (string) $log['time'], wp_timezone() );
            if ( ! $dt ) {
                // 旧形式や不正値は強制的に保管対象外
                return false;
            }
            $time = $dt->getTimestamp();
            return $time >= $threshold;
        } );

        return array_values( $logs );
    }

    public static function get_all() {
        // 読み取り専用にする (重要):
        //   旧実装は毎回 update_option を呼んでいたが、これは別プロセスが add() で
        //   新ログを書き込んでいる最中に古い prune 結果で上書きする競合を生み、
        //   ログが消失する可能性があった。
        //   prune そのものは add() 時に実施済み (永続化された配列は常に N 日以内のもの)。
        //   ここでは「表示用フィルタ (壊れた要素の除外)」のみ行い、永続化はしない。
        $logs = get_option( self::OPTION_KEY, array() );
        if ( ! is_array( $logs ) ) {
            return array();
        }
        $logs = array_values( array_filter( $logs, function ( $log ) {
            return is_array( $log ) && ! empty( $log['time'] );
        } ) );
        return $logs;
    }

    /**
     * 古いログを永続的に削除する。
     *
     * get_all() が読み取り専用になったため、ディスク上のクリーンアップが必要な場合は
     * このメソッドを明示的に呼ぶ。add() で実行されるが、明示クリーンアップ用にも提供。
     * AIAP_Lock を取得して並列 add() との競合を防ぐ。
     */
    public static function prune_and_save() {
        // Bug fix (audit r9): release はトークンを渡してレース時の他者ロック誤削除を防ぐ。
        $lock_token = false;
        if ( class_exists( 'AIAP_Lock' ) ) {
            $lock_token = AIAP_Lock::acquire_with_retry( 'logger', 5, 5, 10000 );
        }
        try {
            $logs   = get_option( self::OPTION_KEY, array() );
            $pruned = self::prune_old_logs( $logs );
            // 件数が変わった時のみ書き込む(無駄な write を回避)
            if ( ! is_array( $logs ) || count( $pruned ) !== count( $logs ) ) {
                update_option( self::OPTION_KEY, $pruned, false );
            }
        } finally {
            if ( $lock_token && class_exists( 'AIAP_Lock' ) ) {
                AIAP_Lock::release( 'logger', $lock_token );
            }
        }
    }

    public static function clear() {
        delete_option( self::OPTION_KEY );
    }

    /**
     * vsprintf 失敗時のデバッグ出力 (WP_DEBUG 時のみ)
     *
     * テンプレートの format 指示子と params 数が不一致になった場合、
     * UI 上は素のテンプレート文字列が見えるだけで原因特定が難しい。
     * WP_DEBUG 環境では PHP エラーログに不一致情報を残す。
     */
    private static function log_debug_format_mismatch( $key, $template, $params ) {
        if ( ! ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
            return;
        }
        // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
        error_log( sprintf(
            'AIAP_Logger: vsprintf failed for key=%s template=%s params=%s',
            $key,
            $template,
            wp_json_encode( $params )
        ) );
    }
}
