<?php
/**
 * One shared option-backed mutex for every lock in the plugin.
 *
 * Before 0.38.0 the same acquire/release pattern lived in several callers and
 * each had the same takeover race: between reading an expired lock and deleting
 * it, another process could acquire a
 * fresh one, which the first process then deleted, so two workers held the
 * lock at once. This class fixes it once with an atomic compare-and-delete:
 * the expired row is removed only when it still holds exactly the value we
 * read, so the loser's delete simply misses and the loser fails closed.
 *
 * The value is a plain "{token}:{expires_at}" string, not an array, because
 * only an unserialized string can be compared byte-for-byte inside a single
 * SQL DELETE.
 *
 * @package MarketingConsole
 */

namespace MarketingConsole\Core;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Token-owned, TTL-bounded mutex over one non-autoloaded option row.
 */
final class OptionLock {

	/**
	 * Try to acquire the named lock.
	 *
	 * @param string   $option Lock option name.
	 * @param int      $ttl    Seconds after which the lock counts as abandoned.
	 * @param int|null $now    Testable current time.
	 * @return string Owned token, or an empty string when the lock is taken.
	 */
	public static function acquire( string $option, int $ttl, ?int $now = null ): string {
		$now   = null === $now ? time() : $now;
		$token = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : hash( 'sha256', uniqid( '', true ) );
		$value = $token . ':' . ( $now + max( 1, $ttl ) );

		if ( add_option( $option, $value, '', 'no' ) ) {
			return $token;
		}

		$current = get_option( $option, '' );

		// Zámek z verzí do 0.37.0 byl pole. Tyhle verze nikdy nevyšly veřejně,
		// takže migrační větev neexistuje: pole se bere jako cizí obsah a
		// vlastnictví se nepřebírá. Bez ní nejde smazat cizí čerstvý zámek.
		if ( ! is_string( $current ) ) {
			return '';
		}

		if ( '' === $current ) {
			// Řádek zmizel mezi neúspěšným add_option a čtením: jeden nový pokus.
			return add_option( $option, $value, '', 'no' ) ? $token : '';
		}

		$expires = self::expires_at( $current );
		if ( $expires >= $now ) {
			return '';
		}

		// Atomické převzetí: smaže se přesně ta propadlá hodnota, kterou jsme
		// četli. Když mezitím zámek převzal někdo jiný, DELETE nic netrefí a
		// vracíme neúspěch místo smazání cizího čerstvého zámku.
		if ( ! self::compare_and_delete( $option, $current ) ) {
			return '';
		}

		return add_option( $option, $value, '', 'no' ) ? $token : '';
	}

	/**
	 * Release the lock, but only when this caller still owns it.
	 *
	 * @param string $option Lock option name.
	 * @param string $token  Token returned by acquire().
	 * @return void
	 */
	public static function release( string $option, string $token ): void {
		if ( '' === $token ) {
			return;
		}

		$current = get_option( $option, '' );

		if ( is_string( $current ) && '' !== $current && hash_equals( self::token_of( $current ), $token ) ) {
			self::compare_and_delete( $option, $current );
		}
	}

	/**
	 * @param string $value Stored lock value.
	 * @return string
	 */
	private static function token_of( string $value ): string {
		$pos = strrpos( $value, ':' );

		return false === $pos ? '' : substr( $value, 0, $pos );
	}

	/**
	 * @param string $value Stored lock value.
	 * @return int
	 */
	private static function expires_at( string $value ): int {
		$pos = strrpos( $value, ':' );

		return false === $pos ? 0 : (int) substr( $value, $pos + 1 );
	}

	/**
	 * Delete the option row only while it still holds exactly this value.
	 *
	 * @param string $option Lock option name.
	 * @param string $value  Value read moments ago.
	 * @return bool True when this call removed the row.
	 */
	private static function compare_and_delete( string $option, string $value ): bool {
		global $wpdb;

		if ( ! isset( $wpdb ) || ! is_object( $wpdb ) || ! isset( $wpdb->options ) || ! method_exists( $wpdb, 'query' ) ) {
			// Testovací prostředí bez databáze: chová se postaru (bez CAS).
			return delete_option( $option );
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Atomic compare-and-delete cannot use the non-atomic options API.
		$deleted = $wpdb->query(
			$wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", $option, $value )
		);

		// Přímý DELETE obchází options cache; bez invalidace by add_option na
		// webu s Redisem viděl smazaný zámek jako stále existující.
		if ( function_exists( 'wp_cache_delete' ) ) {
			wp_cache_delete( $option, 'options' );
		}

		return (bool) $deleted;
	}
}
