<?php
/**
 * WordPress-backed option store.
 *
 * Thin adapter that implements {@see OptionStore} against the WordPress
 * options API (`get_option()`, `update_option()`, `delete_option()`).
 * This is the production implementation used by every persistence-layer
 * call site; pure-unit tests should inject {@see InMemoryOptionStore}
 * instead so they remain runnable without a WordPress install.
 *
 * The adapter is intentionally allocation-free on the hot read path:
 * `get_option()` already returns the default when the key is unset, so
 * we forward the caller's default verbatim without any extra probing.
 *
 * Autoload behavior:
 *
 *   - Writes go through `update_option()` with its default autoload
 *     behavior so that low-volume plugin options (schema version,
 *     feature flags) are loaded with the rest of the site's autoloaded
 *     options. Higher-volume or larger values that should NOT be
 *     autoloaded must be written through a dedicated call site that
 *     passes an explicit autoload flag; this adapter deliberately does
 *     not expose that knob because every current caller wants the
 *     default behavior.
 *
 *   - Deletes use `delete_option()` which removes the row regardless of
 *     the autoload flag it was stored with.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

/**
 * {@see OptionStore} implementation backed by the WordPress options API.
 *
 * See the file-level docblock for autoload semantics and the rationale
 * for keeping this adapter deliberately narrow.
 */
final class WpOptionStore implements OptionStore {

	/**
	 * Sentinel used to distinguish "unset" from "stored as null/false".
	 *
	 * WordPress's `get_option()` returns the supplied default when the
	 * row is missing, but also returns `false` when the stored value is
	 * literally `false`. To answer {@see self::has()} reliably we probe
	 * with a sentinel that no real caller could ever store, and compare
	 * the result against it by identity.
	 */
	private const UNSET_SENTINEL = '__clb_option_unset_sentinel__';

	/**
	 * Read the value stored under the given key.
	 *
	 * Forwards directly to `get_option()` so the caller's fallback is
	 * returned verbatim when the key is unset.
	 *
	 * The parameter is named `$fallback` rather than `$default` to avoid
	 * the PHP reserved keyword warning raised by PHPCS's
	 * Universal.NamingConventions.NoReservedKeywordParameterNames sniff;
	 * the semantics are identical to WordPress's `get_option( $key, $default )`.
	 *
	 * @param string $key      Option key, including any plugin prefix.
	 * @param mixed  $fallback Value returned when the key is unset.
	 *
	 * @return mixed The stored value, or `$fallback` when unset.
	 */
	public function get( string $key, $fallback = null ) {
		return get_option( $key, $fallback );
	}

	/**
	 * Write the value under the given key (upsert).
	 *
	 * Delegates to `update_option()`, which performs an insert-or-update
	 * and returns false when the stored value is already identical to
	 * `$value`. Callers that need to distinguish "no-op" from "failure"
	 * must probe {@see self::has()} first; this adapter follows the
	 * WordPress convention and does not differentiate.
	 *
	 * @param string $key   Option key, including any plugin prefix.
	 * @param mixed  $value Value to store. Any type accepted by WordPress.
	 *
	 * @return bool True when the stored value changed, false otherwise.
	 */
	public function update( string $key, $value ): bool {
		return (bool) update_option( $key, $value );
	}

	/**
	 * Delete the value stored under the given key.
	 *
	 * @param string $key Option key, including any plugin prefix.
	 *
	 * @return bool True when a value existed and was removed, false when
	 *              no value was stored under the key.
	 */
	public function delete( string $key ): bool {
		return (bool) delete_option( $key );
	}

	/**
	 * Report whether a value is stored under the given key.
	 *
	 * Uses an unlikely sentinel string as the default so that a literal
	 * stored `false` / `null` / `0` is correctly reported as "present".
	 *
	 * @param string $key Option key, including any plugin prefix.
	 */
	public function has( string $key ): bool {
		$value = get_option( $key, self::UNSET_SENTINEL );
		return $value !== self::UNSET_SENTINEL;
	}
}
