<?php

namespace MediaWiki\Settings\Cache;

use MediaWiki\Settings\SettingsBuilderException;
use MediaWiki\Settings\Source\SettingsIncludeLocator;
use MediaWiki\Settings\Source\SettingsSource;
use Stringable;
use Wikimedia\ObjectCache\BagOStuff;
use Wikimedia\WaitConditionLoop;

/**
 * Provides a caching layer for a {@link CacheableSource}.
 *
 * @newable
 * @since 1.38
 */
class CachedSource implements Stringable, SettingsSource, SettingsIncludeLocator {
	/**
	 * Cached source generation timeout (in seconds).
	 */
	private const TIMEOUT = 2;

	/** @var BagOStuff */
	private $cache;

	/** @var CacheableSource */
	private $source;

	/**
	 * Constructs a new CachedSource using an instantiated cache and
	 * {@link CacheableSource}.
	 *
	 * @stable to call
	 *
	 * @param BagOStuff $cache
	 * @param CacheableSource $source
	 */
	public function __construct(
		BagOStuff $cache,
		CacheableSource $source
	) {
		$this->cache = $cache;
		$this->source = $source;
	}

	/**
	 * Queries cache for source contents and performs loading/caching of the
	 * source contents on miss.
	 *
	 * If the load fails but the source implements {@link
	 * CacheableSource::allowsStaleLoad()} as <code>true</code>, stale results
	 * may be returned if still present in the cache store.
	 *
	 * @return array
	 */
	public function load(): array {
		$key = $this->cache->makeGlobalKey(
			__CLASS__,
			$this->source->getHashKey()
		);

		$result = null;
		$loop = new WaitConditionLoop(
			function () use ( $key, &$result ) {
				$item = $this->cache->get( $key );

				if ( $this->isValidHit( $item ) ) {
					if ( $this->isExpired( $item ) ) {
						// The cached item is stale but use it as a default in
						// case of failure if the source allows that
						if ( $this->source->allowsStaleLoad() ) {
							$result = $item['value'];
						}
					} else {
						$result = $item['value'];
						return WaitConditionLoop::CONDITION_REACHED;
					}
				}

				if ( $this->cache->lock( $key, 0, self::TIMEOUT ) ) {
					try {
						$result = $this->loadAndCache( $key );
					} catch ( SettingsBuilderException $e ) {
						if ( $result === null ) {
							// We have a failure and no stale result to fall
							// back on, so throw
							throw $e;
						}
					} finally {
						$this->cache->unlock( $key );
					}
				}

				return $result === null
					? WaitConditionLoop::CONDITION_CONTINUE
					: WaitConditionLoop::CONDITION_REACHED;
			},
			self::TIMEOUT
		);

		if ( $loop->invoke() !== WaitConditionLoop::CONDITION_REACHED ) {
			throw new SettingsBuilderException(
				'Exceeded {timeout}s timeout attempting to load and cache source {source}',
				[
					'timeout' => self::TIMEOUT,
					'source' => $this->source,
				]

			);
		}

		// @phan-suppress-next-line PhanTypeMismatchReturn WaitConditionLoop throws or value set
		return $result;
	}

	/**
	 * Returns the string representation of the encapsulated source.
	 *
	 * @return string
	 */
	public function __toString(): string {
		return $this->source->__toString();
	}

	/**
	 * Whether the given cache item is considered a cache hit, in other words:
	 *  - it is not a falsey value
	 *  - it has the correct type and structure for this cache implementation
	 *
	 * @param mixed $item Cache item.
	 *
	 * @return bool
	 */
	private function isValidHit( $item ): bool {
		return $item &&
			is_array( $item ) &&
			isset( $item['expiry'] ) &&
			isset( $item['generation'] ) &&
			isset( $item['value'] );
	}

	/**
	 * Whether the given cache item is considered expired, in other words:
	 *	- its expiry timestamp has passed
	 *	- it is deemed to expire early so as to mitigate cache stampedes
	 *
	 * @param array $item Cache item.
	 *
	 * @return bool
	 */
	private function isExpired( $item ): bool {
		return $item['expiry'] < microtime( true ) ||
			$this->expiresEarly( $item, $this->source->getExpiryWeight() );
	}

	/**
	 * Decide whether the cached source should be expired early according to a
	 * probabilistic calculation that becomes more likely as the normal expiry
	 * approaches.
	 *
	 * In other words, we're going to pretend we're a bit further into the
	 * future than we are so that we might expire and regenerate the cached
	 * settings before other threads attempt to the do the same. The number of
	 * threads that will pretend to be far into the future (and thus will
	 * concurrently reload/cache the settings) will most probably be so
	 * exponentially fewer than the number of threads pretending to be near
	 * into the future that it will approach optimal stampede protection
	 * without the use of an exclusive lock.
	 *
	 * @param array $item Cached source with expiry metadata.
	 * @param float $weight Coefficient used to increase/decrease the
	 *  likelihood of early expiration.
	 *
	 * @link https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration
	 *
	 * @return bool
	 */
	private function expiresEarly( array $item, float $weight ): bool {
		if ( $weight == 0 || !isset( $item['expiry'] ) || !isset( $item['generation'] ) ) {
			return false;
		}

		// Calculate a negative expiry offset using generation time, expiry
		// weight, and a random number within the exponentially distributed
		// range of log n where n: (0, 1] (which is always negative)
		$expiryOffset =
			$item['generation'] *
			$weight *
			log( random_int( 1, PHP_INT_MAX ) / PHP_INT_MAX );

		return ( $item['expiry'] + $expiryOffset ) <= microtime( true );
	}

	/**
	 * Loads the source and caches the result.
	 *
	 * @param string $key
	 *
	 * @return array
	 */
	private function loadAndCache( string $key ): array {
		$ttl =
			$this->source->allowsStaleLoad()
			? BagOStuff::TTL_INDEFINITE
			: $this->source->getExpiryTtl();

		$item = $this->loadWithMetadata();
		$this->cache->set( $key, $item, $ttl );
		return $item['value'];
	}

	/**
	 * Wraps cached source with the metadata needed to perform probabilistic
	 * early expiration to help mitigate cache stampedes.
	 *
	 * @return array
	 */
	private function loadWithMetadata(): array {
		$start = microtime( true );
		$value = $this->source->load();
		$finish = microtime( true );

		return [
			'value' => $value,
			'expiry' => $start + $this->source->getExpiryTtl(),
			'generation' => $finish - $start,
		];
	}

	public function locateInclude( string $location ): string {
		if ( $this->source instanceof SettingsIncludeLocator ) {
			return $this->source->locateInclude( $location );
		} else {
			// Just return the location as-is
			return $location;
		}
	}
}
