<?php
/**
 * @license GPL-2.0-or-later
 * @file
 */

declare( strict_types=1 );

namespace Wikimedia\Stats;

use InvalidArgumentException;
use MediaWiki\Logger\LoggerFactory;
use OutOfBoundsException;
use OutOfRangeException;
use Psr\Log\LoggerInterface;
use Wikimedia\Stats\Emitters\NullEmitter;
use Wikimedia\Stats\Formatters\DogStatsdFormatter;
use Wikimedia\Stats\Metrics\MetricInterface;

/**
 * A helper class for testing metrics in Unit Tests.
 *
 * @author Cole White
 * @since 1.44
 */
class UnitTestingHelper {
	public const EQUALS = '=';
	public const NOT_EQUALS = '!=';
	public const EQUALS_REGEX = '=~';
	public const NOT_EQUALS_REGEX = '!~';

	private StatsCache $cache;
	private StatsFactory $factory;
	private string $component = '';
	private LoggerInterface $logger;

	/**
	 * @internal Use StatsFactory::newUnitTestingHelper() instead.
	 */
	public function __construct() {
		$this->cache = new StatsCache();
		$this->logger = LoggerFactory::getInstance( 'Stats' );
		// Disable StatsFactory::flush() and its StatsCache::clear() calls, because automatic
		// flushes would otherwise delete metrics before we can assert them, e.g. after whenever
		// a subject under test commits a database transaction or when a tested Maintenance script
		// prints output.
		$this->factory = new class( $this->cache, new NullEmitter(), $this->logger ) extends StatsFactory {
			public function flush(): void {
			}
		};
	}

	/**
	 * Set a component on the underlying StatsFactory
	 */
	public function withComponent( string $component ): self {
		$this->factory = $this->factory->withComponent( $component );
		$this->component = $component;
		return $this;
	}

	/**
	 * Get the underlying StatsFactory, to pass to your subject under test.
	 */
	public function getStatsFactory(): StatsFactory {
		return $this->factory;
	}

	/**
	 * Get all samples in dogstatsd format
	 *
	 * @return string[]
	 */
	public function getAllFormatted(): array {
		$output = [];
		$dogFmt = new DogStatsdFormatter();
		foreach ( $this->cache->getAllMetrics() as $metric ) {
			$output = array_merge( $output, $dogFmt->getFormattedSamples( 'mediawiki', $metric ) );
		}
		return $output;
	}

	/**
	 * Get all samples in dogstatsd format and clear the buffer
	 *
	 * @return string[]
	 */
	public function consumeAllFormatted(): array {
		$output = $this->getAllFormatted();
		$this->cache->clear();
		return $output;
	}

	/**
	 * How many samples were observed for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->count( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function count( string $selector ): int {
		return count( $this->getFilteredSamples( $selector ) );
	}

	/**
	 * The last recorded sample value for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->last( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function last( string $selector ): float {
		$samples = $this->getFilteredSamples( $selector );
		return $samples[array_key_last( $samples )]->getValue();
	}

	/**
	 * The sum of all sample values for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->sum( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function sum( string $selector ): float {
		$output = 0;
		foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
			$output += $sample->getValue();
		}
		return $output;
	}

	/**
	 * The max of all sample values for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->max( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function max( string $selector ): float {
		$output = 0;
		foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
			if ( $sample->getValue() > $output ) {
				$output = $sample->getValue();
			}
		}
		return $output;
	}

	/**
	 * The median of all sample values for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->median( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function median( string $selector ): float {
		return $this->sum( $selector ) / $this->count( $selector );
	}

	/**
	 * The min of all sample values for a given metric.
	 *
	 * Example:
	 * ```php
	 * $unitTestingHelper->min( 'the_metric_name{fooLabel="bar"}' );
	 * ```
	 */
	public function min( string $selector ): float {
		$output = INF;
		foreach ( $this->getFilteredSamples( $selector ) as $sample ) {
			if ( $sample->getValue() < $output ) {
				$output = $sample->getValue();
			}
		}
		return $output;
	}

	private function getMetricFromSelector( string $selector ): MetricInterface {
		$key = StatsCache::cacheKey( $this->component, $this->getName( $selector ) );
		$metric = $this->cache->getAllMetrics()[$key] ?? null;
		if ( $metric === null ) {
			$actual = 'Actual metrics:';
			foreach ( $this->cache->getAllMetrics() as $metric ) {
				$name = $metric->getName();
				$sampleCount = $metric->getSampleCount();
				$actual .= "\n  $name ($sampleCount samples)";
			}
			throw new OutOfBoundsException( "Could not find metric with key '$key'\n\n$actual" );
		}
		return $metric;
	}

	private function getFilteredSamples( string $selector ): array {
		$metric = $this->getMetricFromSelector( $selector );
		$filters = $this->getFilters( $selector );
		$labelKeys = $metric->getLabelKeys();
		$left = $metric->getSamples();
		foreach ( $filters as $filter ) {
			$right = [];
			[ $key, $value, $operator ] = $filter;
			$labelPosition = array_search( $key, $labelKeys );
			foreach ( $left as $sample ) {
				if ( $this->matches( $sample->getLabelValues()[$labelPosition], $value, $operator ) ) {
					$right[] = $sample;
				}
			}
			$left = $right;
		}
		if ( !$left ) {
			$dogFmt = new DogStatsdFormatter();
			$actual = 'Actual samples:'
				. "\n" . implode( "\n", $dogFmt->getFormattedSamples( 'mediawiki', $metric ) );
			throw new OutOfRangeException( "Metric selector '$selector' matched zero samples.\n\n$actual" );
		}
		return $left;
	}

	private function getName( string $selector ): string {
		$selector = preg_replace( '/\'/', '"', $selector );
		if ( str_contains( $selector, '{' ) ) {
			$selector = substr( $selector, 0, strpos( $selector, '{' ) );
		}
		if ( !$selector ) {
			throw new InvalidArgumentException( 'Selector cannot be empty.' );
		}
		return $selector;
	}

	private function getFilters( string $selector ): array {
		$selector = preg_replace( '/\'/', '"', $selector );
		if ( !str_contains( $selector, '{' ) && !str_contains( $selector, ',' ) ) {
			return [];
		}
		$output = [];
		$filters = substr( $selector, strpos( $selector, '{' ) + 1, -1 );
		$filters = explode( ',', $filters );
		foreach ( $filters as $filter ) {
			$output[] = $this->getFilterComponents( $filter );
		}
		return $output;
	}

	private function getFilterComponents( string $filter ): array {
		$key = null;
		$value = null;
		$operator = null;
		if ( str_contains( $filter, self::EQUALS ) ) {
			[ $key, $value ] = explode( self::EQUALS, $filter );
			$operator = self::EQUALS;
		}
		if ( str_contains( $filter, self::EQUALS_REGEX ) ) {
			[ $key, $value ] = explode( self::EQUALS_REGEX, $filter );
			$operator = self::EQUALS_REGEX;
		}
		if ( str_contains( $filter, self::NOT_EQUALS_REGEX ) ) {
			[ $key, $value ] = explode( self::NOT_EQUALS_REGEX, $filter );
			$operator = self::NOT_EQUALS_REGEX;
		}
		if ( str_contains( $filter, self::NOT_EQUALS ) ) {
			[ $key, $value ] = explode( self::NOT_EQUALS, $filter );
			$operator = self::NOT_EQUALS;
		}
		if ( !$key || !$value || !$operator ) {
			$this->logger->debug(
				'Got filter expression: {' . $filter . '}',
				[ 'key' => $key, 'value' => $value, 'operator' => $operator ]
			);
			throw new InvalidArgumentException( "Filter components cannot be empty." );
		}
		$key = preg_replace( '/[^a-zA-Z0-9_]+/', '', $key );
		$value = preg_replace( '/[^a-zA-Z0-9_]+/', '', $value );
		return [ $key, $value, $operator ];
	}

	/**
	 * Return the boolean result of stored and expected values according to the operator.
	 */
	private function matches( string $stored, string $expected, string $operator ): bool {
		if ( $operator === self::NOT_EQUALS ) {
			return $stored != $expected;
		}
		if ( $operator === self::EQUALS_REGEX ) {
			return (bool)preg_match( "/$expected/", $stored );
		}
		if ( $operator === self::NOT_EQUALS_REGEX ) {
			return !preg_match( "/$expected/", $stored );
		}
		return $stored === $expected;
	}
}
