<?php

declare(strict_types=1);

namespace SiteFort\Modules\Tools;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Owner-tokened option-table lock for high-risk tools.
 */
final class ToolLock {

	private const OPTION_PREFIX = 'sitefort_lock_';
	private const DEFAULT_TTL = 900;

	private string $option_name;
	private string $value;
	private bool $released = false;

	private function __construct( string $option_name, string $value ) {
		$this->option_name = $option_name;
		$this->value       = $value;
	}

	/**
	 * Acquire a lock, reclaiming it only after its TTL expires.
	 *
	 * @return self|\WP_Error|null Lock instance, error on database failure, or null when another process holds it.
	 */
	public static function acquire( string $name, int $ttl = self::DEFAULT_TTL ) {
		global $wpdb;

		self::assert_valid_name( $name );

		$options_table = ToolDatabase::table_identifier( $wpdb->options );
		$option_name   = self::OPTION_PREFIX . $name;
		$now           = time();
		$value         = self::build_value( $now + max( 1, $ttl ) );

		// INSERT IGNORE acquires the lock only when the row is absent.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$insert_sql = $wpdb->prepare(
			"INSERT IGNORE INTO {$options_table} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
			$option_name,
			$value
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
		$inserted = $wpdb->query( $insert_sql );
		if ( false === $inserted ) {
			return new \WP_Error(
				'db_error',
				'Unable to create the tool lock.'
			);
		}

		if ( $inserted ) {
			self::flush_option_cache( $option_name );
			return new self( $option_name, $value );
		}

		// Row exists. Check if expired and atomically reclaim with a compare-and-swap update.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$select_sql = $wpdb->prepare(
			"SELECT option_value FROM {$options_table} WHERE option_name = %s LIMIT 1",
			$option_name
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
		$current_value = $wpdb->get_var( $select_sql );
		if ( '' !== (string) $wpdb->last_error ) {
			return new \WP_Error(
				'db_error',
				'Unable to read the existing tool lock.'
			);
		}

		if ( null === $current_value ) {
			// The row disappeared between INSERT IGNORE and SELECT; retry once with the same owner value.
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
			$inserted = $wpdb->query( $insert_sql );
			if ( false === $inserted ) {
				return new \WP_Error(
					'db_error',
					'Unable to create the tool lock after retry.'
				);
			}

			if ( $inserted ) {
				self::flush_option_cache( $option_name );
				return new self( $option_name, $value );
			}

			return null;
		}

		$current_value = (string) $current_value;
		if ( self::parse_expiry( $current_value ) >= $now ) {
			return null;
		}

		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$reclaim_sql = $wpdb->prepare(
			"UPDATE {$options_table} SET option_value = %s WHERE option_name = %s AND option_value = %s",
			$value,
			$option_name,
			$current_value
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
		$reclaimed = $wpdb->query( $reclaim_sql );
		if ( false === $reclaimed ) {
			return new \WP_Error(
				'db_error',
				'Unable to reclaim the expired tool lock.'
			);
		}

		if ( $reclaimed > 0 ) {
			self::flush_option_cache( $option_name );
			return new self( $option_name, $value );
		}

		return null;
	}

	public function release(): void {
		if ( $this->released ) {
			return;
		}

		global $wpdb;

		$options_table = ToolDatabase::table_identifier( $wpdb->options );

		// Release only if this process still owns the exact lock value.
		// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		$delete_sql = $wpdb->prepare(
			"DELETE FROM {$options_table} WHERE option_name = %s AND option_value = %s",
			$this->option_name,
			$this->value
		);
		// phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
		// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Mutex query.
		$wpdb->query( $delete_sql );

		self::flush_option_cache( $this->option_name );
		$this->released = true;
	}

	public function __destruct() {
		$this->release();
	}

	private static function build_value( int $expires ): string {
		$value = [
			'expires' => $expires,
			'token'   => self::random_token(),
		];

		$encoded = function_exists( 'wp_json_encode' ) ? wp_json_encode( $value ) : json_encode( $value );
		if ( ! is_string( $encoded ) || '' === $encoded ) {
			throw new \RuntimeException( 'Unable to encode lock value.' );
		}

		return $encoded;
	}

	private static function parse_expiry( string $value ): int {
		$decoded = json_decode( $value, true );
		if ( ! is_array( $decoded ) ) {
			return 0;
		}

		return max( 0, (int) ( $decoded['expires'] ?? 0 ) );
	}

	private static function random_token(): string {
		try {
			return bin2hex( random_bytes( 16 ) );
		} catch ( \Throwable $e ) {
			return md5( uniqid( '', true ) );
		}
	}

	private static function assert_valid_name( string $name ): void {
		if ( '' === $name || strlen( $name ) > 120 || ! preg_match( '/^[A-Za-z0-9_-]+$/', $name ) ) {
			throw new \InvalidArgumentException( 'Invalid lock name.' );
		}
	}

	private static function flush_option_cache( string $option_name ): void {
		wp_cache_delete( $option_name, 'options' );
		wp_cache_delete( 'notoptions', 'options' );
	}

}
