<?php
/** Create-only storage primitives for internal Companion options. */

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

function wpagent_companion_forget_option_cache( string $option, bool $autoloaded = true ): void {
	wp_cache_delete( $option, 'options' );
	if ( $autoloaded ) {
		wp_cache_delete( 'alloptions', 'options' );
	}
	$notoptions = wp_cache_get( 'notoptions', 'options' );
	if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
		unset( $notoptions[ $option ] );
		wp_cache_set( 'notoptions', $notoptions, 'options' );
	}
}

/** @return array{raw:string,autoload:string}|null */
function wpagent_companion_read_option_row_direct( string $option ): ?array {
	global $wpdb;
	$row = $wpdb->get_row(
		$wpdb->prepare(
			"SELECT option_value, autoload FROM {$wpdb->options} WHERE option_name = %s",
			$option
		),
		ARRAY_A
	);
	if ( ! is_array( $row ) || ! is_string( $row['option_value'] ?? null ) || ! is_string( $row['autoload'] ?? null ) ) {
		return null;
	}
	return array(
		'raw'      => $row['option_value'],
		'autoload' => $row['autoload'],
	);

}

function wpagent_companion_read_option_raw_direct( string $option ): ?string {
	$row = wpagent_companion_read_option_row_direct( $option );
	return is_array( $row ) ? $row['raw'] : null;
}

/** @return array<string,mixed>|null */
function wpagent_companion_read_option_record_direct( string $option ): ?array {
	$raw = wpagent_companion_read_option_raw_direct( $option );
	if ( null === $raw ) {
		return null;
	}
	$value = maybe_unserialize( $raw );
	return is_array( $value ) ? $value : null;
}

/** @param array<string,mixed> $record */
function wpagent_companion_option_lock_record_is_valid( array $record, string $expires_field = 'expires' ): bool {
	return '' !== $expires_field
		&& is_string( $record['token'] ?? null )
		&& '' !== $record['token']
		&& is_int( $record[ $expires_field ] ?? null );
}

function wpagent_companion_option_lock_is_owned( string $option, string $token, string $expires_field = 'expires' ): bool {
	if ( '' === $option || '' === $token || '' === $expires_field ) {
		return false;
	}
	$record = wpagent_companion_read_option_record_direct( $option );
	return is_array( $record )
		&& wpagent_companion_option_lock_record_is_valid( $record, $expires_field )
		&& hash_equals( $record['token'], $token )
		&& $record[ $expires_field ] > time();
}

/**
 * Atomically renews an owned lock and proves that its database row still
 * belongs to the caller. The expiry always changes, even within one second.
 */
function wpagent_companion_refresh_option_lock( string $option, string $token, int $ttl, string $expires_field = 'expires' ): bool {
	global $wpdb;
	if ( '' === $option || '' === $token || '' === $expires_field || $ttl < 1 ) {
		return false;
	}
	$row     = wpagent_companion_read_option_row_direct( $option );
	$current = is_array( $row ) ? maybe_unserialize( $row['raw'] ) : null;
	if ( ! is_array( $current )
		|| ! wpagent_companion_option_lock_record_is_valid( $current, $expires_field )
		|| ! hash_equals( $current['token'], $token )
		|| $current[ $expires_field ] <= time() ) {
		return false;
	}
	$next                   = $current;
	$next[ $expires_field ] = max( time() + $ttl, $current[ $expires_field ] + 1 );
	$updated                = $wpdb->query(
		$wpdb->prepare(
			"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND HEX(option_value) = HEX(%s)",
			maybe_serialize( $next ),
			$option,
			maybe_serialize( $current )
		)
	);
	wpagent_companion_forget_option_cache(
		$option,
		is_array( $row ) && in_array( $row['autoload'], wp_autoload_values_to_autoload(), true )
	);
	return 1 === $updated && wpagent_companion_option_lock_is_owned( $option, $token, $expires_field );
}

/**
 * Acquires an internal option-backed lock without overwriting a contender.
 *
 * Expired records are removed only through a byte-exact compare-and-delete.
 * Malformed records remain in place so storage corruption cannot silently
 * weaken mutual exclusion and remains available for manual diagnosis.
 *
 * @return string|false Lock-owner token on success, false on contention or storage failure.
 */
function wpagent_companion_acquire_option_lock( string $option, int $ttl, string $expires_field = 'expires' ) {
	global $wpdb;
	$option = trim( $option );
	if ( '' === $option || $ttl < 1 || '' === $expires_field ) {
		return false;
	}
	try {
		$token = bin2hex( random_bytes( 16 ) );
	} catch ( Throwable $error ) {
		$token = wp_generate_uuid4();
	}
	$record = array( 'token' => $token, $expires_field => time() + $ttl );
	if ( wpagent_companion_add_option_create_only( $option, $record, '', false ) ) {
		return wpagent_companion_option_lock_is_owned( $option, $token, $expires_field ) ? $token : false;
	}
	$create_error = $wpdb->last_error;

	$row = wpagent_companion_read_option_row_direct( $option );
	if ( ! is_array( $row ) ) {
		if ( '' !== $create_error ) {
			$wpdb->last_error = $create_error;
			return false;
		}
		// add_option_create_only() may have returned from its per-process cache
		// precheck while another worker released the lock. The direct read proves
		// that no row exists now, so clear that stale cache and retry the atomic
		// insert once. A new contender still loses to the unique option_name key.
		wpagent_companion_forget_option_cache( $option, true );
		if ( wpagent_companion_add_option_create_only( $option, $record, '', false ) ) {
			return wpagent_companion_option_lock_is_owned( $option, $token, $expires_field ) ? $token : false;
		}
		return false;
	}
	$raw     = $row['raw'];
	$current = maybe_unserialize( $raw );
	if ( ! is_array( $current )
		|| ! is_string( $current['token'] ?? null )
		|| '' === $current['token']
		|| ! is_int( $current[ $expires_field ] ?? null )
		|| $current[ $expires_field ] > time() ) {
		return false;
	}

	$deleted = $wpdb->query(
		$wpdb->prepare(
			"DELETE FROM {$wpdb->options} WHERE option_name = %s AND HEX(option_value) = HEX(%s)",
			$option,
			$raw
		)
	);
	if ( 1 !== $deleted ) {
		return false;
	}
	wpagent_companion_forget_option_cache(
		$option,
		in_array( $row['autoload'], wp_autoload_values_to_autoload(), true )
	);
	if ( ! wpagent_companion_add_option_create_only( $option, $record, '', false ) ) {
		return false;
	}
	return wpagent_companion_option_lock_is_owned( $option, $token, $expires_field ) ? $token : false;
}

/** Releases an internal option-backed lock only while the exact record is owned. */
function wpagent_companion_release_option_lock( string $option, string $token, string $expires_field = 'expires' ): void {
	global $wpdb;
	$option = trim( $option );
	if ( '' === $option || '' === $token || '' === $expires_field ) {
		return;
	}
	$row = wpagent_companion_read_option_row_direct( $option );
	if ( ! is_array( $row ) ) {
		return;
	}
	$raw     = $row['raw'];
	$current = maybe_unserialize( $raw );
	if ( ! is_array( $current )
		|| ! wpagent_companion_option_lock_record_is_valid( $current, $expires_field )
		|| ! hash_equals( $current['token'], $token ) ) {
		return;
	}
	$deleted = $wpdb->query(
		$wpdb->prepare(
			"DELETE FROM {$wpdb->options} WHERE option_name = %s AND HEX(option_value) = HEX(%s)",
			$option,
			$raw
		)
	);
	if ( 1 === $deleted ) {
		wpagent_companion_forget_option_cache(
			$option,
			in_array( $row['autoload'], wp_autoload_values_to_autoload(), true )
		);
	}
}

/**
 * Adds an option only if no row with the same name exists.
 *
 * WordPress 7.0 implements add_option() with an upsert. Its cache precheck is
 * not atomic, so two concurrent callers can both pass it and the later query
 * can overwrite the first caller's lock, session or compare-and-swap record.
 * This helper preserves add_option()'s documented create-only behavior while
 * retaining its sanitization, autoload cache and action contracts.
 *
 * @param mixed  $value      Option value.
 * @param string $deprecated Deprecated compatibility argument.
 * @param mixed  $autoload   Whether to autoload the option.
 */
function wpagent_companion_add_option_create_only( string $option, $value = '', string $deprecated = '', $autoload = null ): bool {
	global $wpdb;

	$option = trim( $option );
	if ( '' === $option ) {
		return false;
	}
	if ( '' !== $deprecated ) {
		_deprecated_argument( __FUNCTION__, '0.4.73' );
	}
	wp_protect_special_option( $option );

	if ( is_object( $value ) ) {
		$value = clone $value;
	}
	$value = sanitize_option( $option, $value );

	$notoptions = wp_cache_get( 'notoptions', 'options' );
	if ( ! is_array( $notoptions ) || ! isset( $notoptions[ $option ] ) ) {
		if ( apply_filters( "default_option_{$option}", false, $option, false ) !== get_option( $option ) ) {
			return false;
		}
	}

	$serialized_value = maybe_serialize( $value );
	$autoload_value   = wp_determine_option_autoload_value( $option, $value, $serialized_value, $autoload );
	do_action( 'add_option', $option, $value );

	$previous_suppression = $wpdb->suppress_errors( true );
	try {
		$inserted = $wpdb->insert(
			$wpdb->options,
			array(
				'option_name'  => $option,
				'option_value' => $serialized_value,
				'autoload'     => $autoload_value,
			),
			array( '%s', '%s', '%s' )
		);
		$insert_error = $wpdb->last_error;
	} finally {
		$wpdb->suppress_errors( $previous_suppression );
	}
	if ( 1 !== $inserted ) {
		// A competing insert is expected. Re-read the table directly and drop
		// any stale notoptions entry so callers observe the winning record.
		$existing_autoload = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT autoload FROM {$wpdb->options} WHERE option_name = %s",
				$option
			)
		);
		if ( is_string( $existing_autoload ) ) {
			// A uniqueness conflict is normal create-only control flow. Do not
			// leak it as a storage failure to callers that inspect last_error.
			$wpdb->last_error = '';
			wpagent_companion_forget_option_cache(
				$option,
				in_array( $existing_autoload, wp_autoload_values_to_autoload(), true )
			);
		} elseif ( '' === $wpdb->last_error ) {
			// The existence probe can clear the original insert error. Restore it
			// so callers can distinguish storage failure from lock contention.
			$wpdb->last_error = '' !== $insert_error
				? $insert_error
				: 'Create-only option insert failed without creating a row.';
		}
		return false;
	}

	if ( ! wp_installing() ) {
		if ( in_array( $autoload_value, wp_autoload_values_to_autoload(), true ) ) {
			$alloptions            = wp_load_alloptions( true );
			$alloptions[ $option ] = $serialized_value;
			wp_cache_set( 'alloptions', $alloptions, 'options' );
		} else {
			wp_cache_set( $option, $serialized_value, 'options' );
		}
	}

	$notoptions = wp_cache_get( 'notoptions', 'options' );
	if ( is_array( $notoptions ) && isset( $notoptions[ $option ] ) ) {
		unset( $notoptions[ $option ] );
		wp_cache_set( 'notoptions', $notoptions, 'options' );
	}

	do_action( "add_option_{$option}", $option, $value );
	do_action( 'added_option', $option, $value );
	return true;
}
