<?php
/**
 * Cross-request mutual-exclusion helper.
 *
 * Used to guard non-atomic check-then-act sequences (AWB issuance, order
 * import) from racing each other when the same external trigger arrives
 * twice in rapid succession (eMAG webhook retries, double-clicked admin
 * buttons, parallel cron + manual import).
 *
 * Strategy:
 *   1. If a persistent object cache is in play (Redis / Memcached),
 *      use wp_cache_add() — guaranteed atomic across requests.
 *   2. Otherwise fall back to INSERT IGNORE on wp_options. Atomic at the
 *      database level on every storage engine WordPress supports.
 *
 * The fallback intentionally does NOT use set_transient(): get_transient
 * + set_transient is two queries with a race window between them, and
 * transients without an external object cache stash into wp_options
 * anyway, so a direct INSERT IGNORE on the same row is both faster and
 * race-free.
 *
 * @package SkyEmagSync
 */

namespace SkyEmagSync\Utils;

defined( 'ABSPATH' ) || exit;

class Lock {

    const GROUP   = 'skyemag_lock';
    const PREFIX  = '_skyemag_lock_';
    const TTL     = 60; // seconds

    /**
     * Try to acquire a lock. Returns true if we got it, false if another
     * request already holds it.
     */
    public static function acquire( string $key ): bool {
        $cache_key = self::cache_key( $key );

        if ( wp_using_ext_object_cache() ) {
            return (bool) wp_cache_add( $cache_key, time(), self::GROUP, self::TTL );
        }

        global $wpdb;

        // INSERT IGNORE returns 1 on insert, 0 on duplicate key. We keep
        // the row in wp_options under a name we own so the rest of the
        // codebase doesn't accidentally collide with us.
        $option_name = self::option_name( $key );
        $now         = time();

        // First, garbage-collect any stale lock row past its TTL.
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value < %d",
            $option_name,
            $now - self::TTL
        ) );

        // INSERT IGNORE is atomic on the unique key on option_name.
        // Returns 1 if inserted, 0 if a non-stale row already exists.
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic lock acquisition cannot use the options API
        $rows = $wpdb->query( $wpdb->prepare(
            "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
            $option_name,
            (string) $now
        ) );

        return 1 === (int) $rows;
    }

    /**
     * Release a previously-acquired lock. Safe to call even if the lock
     * was not held (no-op).
     */
    public static function release( string $key ): void {
        $cache_key = self::cache_key( $key );

        if ( wp_using_ext_object_cache() ) {
            wp_cache_delete( $cache_key, self::GROUP );
            return;
        }

        global $wpdb;
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- options API would autoload the row needlessly
        $wpdb->delete( $wpdb->options, [ 'option_name' => self::option_name( $key ) ] );
    }

    private static function cache_key( string $key ): string {
        return self::PREFIX . md5( $key );
    }

    private static function option_name( string $key ): string {
        // wp_options.option_name is varchar(191) — md5 keeps us inside the cap.
        return self::PREFIX . md5( $key );
    }
}
