<?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 されずに残ったロックを救済する。
        $current = get_option( $name );
        $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'];
        }
        if ( $expires > 0 && $expires < $now ) {
            update_option( $name, $value, false );
            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
     */
    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経過後に強制奪取したロックを誤削除しないため
    }
}
