<?php

/*
 * This file is part of the PHPBench package
 *
 * (c) Daniel Leech <daniel@dantleech.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 *
 */

namespace PhpBench\Model;

use ReturnTypeWillChange;
use InvalidArgumentException;
use ArrayAccess;
use ArrayIterator;
use Countable;
use Exception;
use IteratorAggregate;
use PhpBench\Assertion\AssertionResult;
use PhpBench\Assertion\VariantAssertionResults;
use PhpBench\Math\Distribution;
use PhpBench\Math\Statistics;
use PhpBench\Model\Result\ComputedResult;
use PhpBench\Model\Result\TimeResult;
use RuntimeException;

/**
 * Stores Iterations and calculates the deviations and rejection
 * status for each based on the given rejection threshold.
 *
 * @implements IteratorAggregate<int, Iteration>
 * @implements ArrayAccess<int, Iteration>
 */
class Variant implements IteratorAggregate, ArrayAccess, Countable
{
    /** @var list<Iteration> */
    private array $iterations = [];

    /** @var list<Iteration> */
    private array $rejects = [];

    private ?ErrorStack $errorStack = null;

    private ?Distribution $stats = null;

    private bool $computed = false;

    private ?\PhpBench\Model\Variant $baseline = null;

    private VariantAssertionResults $assertionResults;

    /**
     * @param array<string, int|float> $computedStats
     */
    public function __construct(
        private readonly Subject $subject,
        private readonly ParameterSet $parameterSet,
        private readonly int $revolutions,
        private readonly int $warmup,
        private readonly array $computedStats = []
    ) {
        $this->assertionResults = new VariantAssertionResults($this, []);
    }

    /**
     * Generate $nbIterations and add them to the variant.
     *
     */
    public function spawnIterations(int $nbIterations): void
    {
        for ($index = 0; $index < $nbIterations; $index++) {
            $this->iterations[] = new Iteration($index, $this);
        }
    }

    /**
     * Create and add a new iteration.
     *
     * @param array<ResultInterface> $results
     */
    public function createIteration(array $results = []): Iteration
    {
        $index = count($this->iterations);
        $iteration = new Iteration($index, $this, $results);
        $this->iterations[] = $iteration;

        return $iteration;
    }

    /**
     * Return the iteration at the given index.
     *
     * @param int $index
     */
    public function getIteration($index): ?Iteration
    {
        return $this->iterations[$index] ?? null;
    }

    /**
     * Add an iteration.
     *
     */
    public function addIteration(Iteration $iteration): void
    {
        $this->iterations[] = $iteration;
    }

    /**
     * @return ArrayIterator<int,Iteration>
     */
    public function getIterator(): ArrayIterator
    {
        return new ArrayIterator($this->iterations);
    }

    /**
     * Return result values by class and metric name.
     *
     * e.g.
     *
     * ```
     * $variant->getMetricValues(ComputedResult::class, 'z_value');
     * ```
     *
     * @return array<int|float>
     */
    public function getMetricValues(string $resultClass, string $metricName): array
    {
        $values = [];

        foreach ($this->iterations as $iteration) {
            if ($iteration->hasResult($resultClass)) {
                $values[] = $iteration->getMetric($resultClass, $metricName);
            }
        }

        return $values;
    }

    /**
     * Return the average metric values by revolution.
     *
     * @return mixed[]
     */
    public function getMetricValuesByRev(string $resultClass, string $metric): array
    {
        return array_map(function ($value) {
            return $value / $this->getRevolutions();
        }, $this->getMetricValues($resultClass, $metric));
    }

    public function resetAssertionResults(): void
    {
        $this->assertionResults = new VariantAssertionResults($this, []);
    }

    /**
     * Calculate and set the deviation from the mean time for each iteration. If
     * the deviation is greater than the rejection threshold, then mark the iteration as
     * rejected.
     */
    public function computeStats(): void
    {
        $this->rejects = [];
        $revs = $this->getRevolutions();

        if (0 === count($this->iterations)) {
            return;
        }

        $times = $this->getMetricValuesByRev(TimeResult::class, 'net');
        $retryThreshold = $this->getSubject()->getRetryThreshold();

        $this->stats = new Distribution($times, $this->computedStats);

        foreach ($this->iterations as $iteration) {
            $timeResult = $iteration->getResult(TimeResult::class);
            assert($timeResult instanceof TimeResult);

            // deviation is the percentage different of the value from the mean of the set.
            if ($this->stats->getMean() > 0) {
                $deviation = 100 / $this->stats->getMean() * (
                    (
                        $timeResult->getRevTime($iteration->getVariant()->getRevolutions())
                    ) - $this->stats->getMean()
                );
            } else {
                $deviation = 0;
            }

            // the Z-Value represents the number of standard deviations this
            // value is away from the mean.
            $revTime = $timeResult->getRevTime($revs);
            $zValue = $this->stats->getStdev() ? ($revTime - $this->stats->getMean()) / $this->stats->getStdev() : 0;

            if (null !== $retryThreshold) {
                if (abs($deviation) >= $retryThreshold) {
                    $this->rejects[] = $iteration;
                }
            }

            $iteration->setResult(new ComputedResult($zValue, $deviation));
        }

        $this->computed = true;
    }

    /**
     * Return the number of rejected iterations.
     *
     */
    public function getRejectCount(): int
    {
        return count($this->rejects);
    }

    /**
     * Return all rejected iterations.
     *
     * @return Iteration[]
     */
    public function getRejects(): array
    {
        return $this->rejects;
    }

    /**
     * Return statistics about this iteration collection.
     *
     * See self::$stats.
     *
     * TODO: Rename to getDistribution
     *
     */
    public function getStats(): Distribution
    {
        if (null !== $this->errorStack) {
            throw new RuntimeException(sprintf(
                'Cannot retrieve stats when an exception was encountered ([%s] %s)',
                $this->errorStack->getTop()->getClass(),
                $this->errorStack->getTop()->getMessage()
            ));
        }

        if (false === $this->computed) {
            throw new RuntimeException(
                'No statistics have yet been computed for this iteration set (::computeStats should be called)'
            );
        }

        return $this->stats;
    }

    /**
     * Return true if the collection has been computed (i.e. stats have been s
     * set and rejects identified).
     */
    public function isComputed(): bool
    {
        return $this->computed;
    }

    /**
     * Return the parameter set.
     */
    public function getParameterSet(): ParameterSet
    {
        return $this->parameterSet;
    }

    /**
     * Return the subject metadata.
     */
    public function getSubject(): Subject
    {
        return $this->subject;
    }

    /**
     * Return true if any of the iterations in this set encountered
     * an error.
     */
    public function hasErrorStack(): bool
    {
        return null !== $this->errorStack;
    }

    /**
     * Should be called when rebuiling the object graph.
     */
    public function getErrorStack(): ErrorStack
    {
        if (null === $this->errorStack) {
            return new ErrorStack($this, []);
        }

        return $this->errorStack;
    }

    /**
     * Create an error stack from an Exception.
     *
     * Should be called when an Exception is encountered during
     * the execution of any of the iteration processes.
     *
     * After an exception is encountered the results from this iteration
     * set are invalid.
     *
     */
    public function setException(Exception $exception): void
    {
        $errors = [];

        do {
            $errors[] = Error::fromException($exception);
        } while ($exception = $exception->getPrevious());

        $this->errorStack = new ErrorStack($this, $errors);
    }

    /**
     * Create and set the error stack from a list of Error instances.
     *
     * @param Error[] $errors
     */
    public function createErrorStack(array $errors): void
    {
        $this->errorStack = new ErrorStack($this, $errors);
    }

    /**
     * Return the number of revolutions for this variant.
     */
    public function getRevolutions(): int
    {
        return $this->revolutions;
    }

    /**
     * Return the number of warmup revolutions.
     */
    public function getWarmup(): int
    {
        return $this->warmup;
    }

    /**
     * Return all the iterations.
     *
     * @return Iteration[]
     */
    public function getIterations(): array
    {
        return $this->iterations;
    }

    /**
     * Return number of iterations.
     */
    public function count(): int
    {
        return count($this->iterations);
    }

    #[ReturnTypeWillChange]
    public function offsetGet($offset): ?Iteration
    {
        return $this->getIteration($offset);
    }

    #[ReturnTypeWillChange]
    public function offsetSet($offset, $value): void
    {
        throw new InvalidArgumentException(
            'Iteration collections are immutable'
        );
    }

    /**
     * {@inheritdoc}
     */
    #[ReturnTypeWillChange]
    public function offsetUnset($offset): void
    {
        throw new InvalidArgumentException(
            'Iteration collections are immutable'
        );
    }

    #[ReturnTypeWillChange]
    public function offsetExists($offset): bool
    {
        return array_key_exists($offset, $this->iterations);
    }

    public function attachBaseline(Variant $baselineVariant): void
    {
        $this->baseline = $baselineVariant;
    }

    public function getBaseline(): ?Variant
    {
        return $this->baseline;
    }

    public function addAssertionResult(AssertionResult $result): void
    {
        $this->assertionResults->add($result);
    }

    public function getAssertionResults(): VariantAssertionResults
    {
        return $this->assertionResults;
    }
}
