<?php

if ( ! defined( 'ABSPATH' ) ) {

    exit;

}



/**

 * wp_options への atomic INSERT を利用したロックヘルパー

 *

 * 実装: add_option() は wp_options.option_name の UNIQUE 制約を利用するため、

 * 同時に同じキーを INSERT しようとすると DB が片方を弾き、もう片方だけが

 * true を返す。これが真の原子性を保証する。

 *

 * (旧実装は wp_cache_add に依存していたが、オブジェクトキャッシュ無し環境では

 *  別プロセスから見て原子性ゼロで、両プロセスが同時にロック取得成功するバグがあった)

 *

 * 想定用途:

 *   - cron 二重発火による記事の重複生成回避

 *   - ロガーの同時書き込みによるログ消失低減

 *   - Free プラン投稿カウンタの並列増加防止

 */

class AIAP_Lock {



    const PREFIX = 'aiap_lock_';



    /**

     * ロックを取得する

     *

     * Bug fix (audit r9): 旧実装は acquire 戻り値 bool / release はキー名のみで動作していた。

     * このため次のレースが発生し得た:

     *   1. プロセス A が acquire 成功 (TTL=300)

     *   2. プロセス A がハングし release されないまま TTL 経過

     *   3. プロセス B が acquire → expires < now で強制奪取に成功

     *   4. ハングしていたプロセス A が復活して release を呼ぶ

     *      → プロセス B のロックを誤って削除

     *   5. プロセス C が直後に acquire 成功 → B と C が並行実行

     *

     * 修正: acquire はトークン文字列を返し、release はそれを必須引数として受ける。

     * トークン不一致時は何もしない。これによって「他プロセスのロックを誤って消す」

     * 動作を阻止する。

     *

     * 互換性: 戻り値が bool → string|false に変わるが、PHP では string も truthy なので

     * `if ( $locked )` 形式の旧コードはそのまま動く。release 呼び出し側はトークンを

     * 受け取って引き渡す必要がある(本コミットで全呼び出し元も同時修正)。

     *

     * @param string $key ロック識別子

     * @param int    $ttl ロックの有効秒数(取得側プロセスがクラッシュしても自動解放されるよう必ず指定)

     * @return string|false 取得できればトークン文字列、既にロックされていれば false

     */

    public static function acquire( $key, $ttl = 300 ) {

        $name  = self::PREFIX . $key;

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

        $now   = time();

        $token = self::generate_token();

        $value = wp_json_encode( array( 'expires' => $now + $ttl, 'token' => $token ) );



        // ===== 真の atomic 取得 =====

        // wp_options への INSERT (add_option) は option_name の UNIQUE 制約により

        // 同時に同じキーを INSERT しようとすると片方しか成功しない。これが DB レベル

        // の原子性を保証する真のロックになる。

        // autoload='no' で wp_load_alloptions のキャッシュ汚染を避ける。

        if ( add_option( $name, $value, '', 'no' ) ) {

            return $token;

        }



        // ===== TTL 超過ロックの強制奪取 =====

        // クラッシュ等で release されずに残ったロックを救済する。

        // 旧実装は get_option → update_option の非原子的な操作だったため、

        // 同時に強制奪取を試みた両プロセスが両方とも acquire 成功してしまうレースが

        // あった。$wpdb->update の「WHERE option_value = 旧値」を使うことで、

        // MySQL レベルの compare-and-swap を実現する。

        global $wpdb;

        // autoload='no' で保存しているため、get_option はキャッシュを使わず毎回 DB を読む。

        // ここでは生のシリアライズ値が必要なので直接 DB から取得する。

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

        $current_raw = $wpdb->get_var( $wpdb->prepare(

            "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",

            $name

        ) );

        if ( $current_raw === null ) {

            // 別プロセスが先に release した → 改めて add_option で取得を試みる

            if ( add_option( $name, $value, '', 'no' ) ) {

                return $token;

            }

            return false;

        }

        $expires      = 0;

        $parsed_ok    = false;

        $decoded      = json_decode( (string) $current_raw, true );

        if ( is_array( $decoded ) && isset( $decoded['expires'] ) ) {

            $expires   = (int) $decoded['expires'];

            $parsed_ok = true;

        } else {

            // 旧フォーマット (option として配列が直接入っていた場合) は maybe_unserialize で取り出す

            $maybe = maybe_unserialize( $current_raw );

            if ( is_array( $maybe ) && isset( $maybe['expires'] ) ) {

                $expires   = (int) $maybe['expires'];

                $parsed_ok = true;

            }

        }

        // Bug fix: option_value が JSON でも serialized でもないゴミ文字列だった場合

        // (外部プラグインの干渉・中途半端な書き込み・手動 SQL 等)、旧実装は expires=0 のまま

        // 「$expires > 0 && $expires < $now」を満たさず、強制奪取分岐に永久に入れなかった。

        // 結果: 「held でない (is_held=false) のに acquire は失敗し続ける」矛盾状態を招く。

        // parse 失敗時は「不正データ=即解放可能」として扱い、強制奪取分岐に流す。

        if ( ! $parsed_ok && $current_raw !== '' ) {

            $expires = 1; // 過去の epoch を立てて下の `$expires < $now` を確実に満たす

        }

        if ( $expires > 0 && $expires < $now ) {

            // 条件付き UPDATE: option_value が現在の生値と一致する場合のみ書き換える。

            // 並行する強制奪取の片方しか 1 行更新できない。

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching

            $updated = $wpdb->update(

                $wpdb->options,

                array( 'option_value' => $value ),

                array( 'option_name' => $name, 'option_value' => $current_raw ),

                array( '%s' ),

                array( '%s', '%s' )

            );

            if ( $updated === 1 ) {

                wp_cache_delete( $name, 'options' );

                return $token;

            }

        }

        return false;

    }



    private static function generate_token() {

        if ( function_exists( 'wp_generate_uuid4' ) ) {

            return wp_generate_uuid4();

        }

        // wp_generate_uuid4 が無い古い WP 用フォールバック

        return uniqid( 'aiap_', true ) . '_' . wp_rand();

    }



    /**

     * ブロッキング取得: 指定回数までリトライしてロック取得を試みる

     *

     * 主にロガーのような短時間処理での衝突回避に使用。

     *

     * @param string $key

     * @param int    $ttl

     * @param int    $retries     リトライ回数

     * @param int    $sleep_us    リトライ間隔(マイクロ秒)

     * @return string|false 取得成功時はトークン、失敗時 false

     */

    /**

     * acquire_with_retry → try/finally → release のテンプレートを一括実行する

     *

     * 旧実装は AIAP_Logger::add / AIAP_License::increment_post_count などで

     * 「class_exists ガード → acquire_with_retry → try { ... } finally { release(token) }」を

     * 各クラスで独立に書いていた (CLAUDE.md 重複排除)。

     *

     * ロック取得失敗時もコールバックは呼ばれる (= 副次的ロックでは「失敗しても処理は続ける」が定石)。

     * 「ロック失敗時は処理しない」を望む呼び出し側は、戻り値 `$result['acquired']` を見て分岐する。

     *

     * @param string   $key      ロック識別子 (AIAP_Lock_Keys の定数推奨)

     * @param int      $ttl      ロック TTL 秒

     * @param int      $retries  リトライ回数

     * @param int      $sleep_us リトライ間隔 (マイクロ秒)

     * @param callable $callback ロック保護下で実行するコールバック。引数は (bool $acquired)。戻り値はそのまま返す

     * @return mixed コールバックの戻り値

     */

    public static function with_lock( $key, $ttl, $retries, $sleep_us, callable $callback ) {

        $token = self::acquire_with_retry( $key, $ttl, $retries, $sleep_us );

        try {

            return $callback( $token !== false );

        } finally {

            if ( $token !== false ) {

                self::release( $key, $token );

            }

        }

    }



    public static function acquire_with_retry( $key, $ttl = 5, $retries = 5, $sleep_us = 10000 ) {

        for ( $i = 0; $i <= $retries; $i++ ) {

            $token = self::acquire( $key, $ttl );

            if ( $token !== false ) {

                return $token;

            }

            if ( $i < $retries ) {

                usleep( $sleep_us );

            }

        }

        return false;

    }



    /**

     * 指定キーのロックが現在有効に保持されているかを返す。

     *

     * 取得・解放は行わない読み取り専用の状態参照。UI でのロック状態表示

     * (例: 自動投稿の進行中インジケータ) などに使う。

     *

     * 旧コードは管理画面側が wp_options を直接読み、ロックキーのプレフィックスと

     * JSON フォーマットを独自に解釈していた。その知識を本クラスに閉じ込める。

     *

     * @param string $key ロック識別子

     * @return bool TTL 内のロックが存在すれば true、無ければ (または期限切れなら) false

     */

    public static function is_held( $key ) {

        $current = get_option( self::PREFIX . $key );

        $expires = 0;

        if ( is_string( $current ) ) {

            $decoded = json_decode( $current, true );

            if ( is_array( $decoded ) && isset( $decoded['expires'] ) ) {

                $expires = (int) $decoded['expires'];

            }

        } elseif ( is_array( $current ) && isset( $current['expires'] ) ) {

            // 旧フォーマット互換 (option として配列が直接入っていた場合)

            $expires = (int) $current['expires'];

        }

        return $expires > time();

    }



    /**

     * ロックを解放する

     *

     * Bug fix (audit r9): トークン検証を追加。$token を渡さない呼び出し(後方互換)は

     * 従来通り無条件削除するが、新しい呼び出し元はトークンを渡してレースを防止する。

     *

     * @param string      $key

     * @param string|null $token acquire / acquire_with_retry の戻り値。null は後方互換用

     */

    public static function release( $key, $token = null ) {

        $name = self::PREFIX . $key;



        // 旧 transient 残骸の掃除 (1.0.0 旧実装 → 新実装への移行時)

        delete_transient( $name );



        // トークン未指定: 後方互換のため無条件削除 (1.0.0 リリース直後の移行期間用)

        if ( $token === null || $token === false || $token === '' ) {

            delete_option( $name );

            return;

        }



        // トークン指定: 自分が取得したロックか確認してから削除する

        $current = get_option( $name );

        if ( ! is_string( $current ) ) {

            // 値が不正(配列等)なら、そもそも他者のロックでない可能性が高い → 削除して掃除

            delete_option( $name );

            return;

        }

        $decoded = json_decode( $current, true );

        if ( ! is_array( $decoded ) || ! isset( $decoded['token'] ) ) {

            // 旧フォーマット (token 無し) のロック残骸 → 安全側で削除

            delete_option( $name );

            return;

        }

        if ( $decoded['token'] === $token ) {

            delete_option( $name );

        }

        // トークン不一致なら何もしない: 他プロセスがTTL経過後に強制奪取したロックを誤削除しないため

    }

}

