// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// This attribute is used to set a retry count on a test method in case of failure.
/// </summary>
/// <remarks>
/// When applied to a test class, the attribute is used as a default for every test method declared on the class.
/// A <see cref="RetryAttribute"/> placed directly on a test method takes precedence over a class-level one
/// (the method-level value fully replaces the class-level value, regardless of whether it is larger or smaller).
/// The attribute is not inherited: applying it to a base test class does not apply it to derived test classes.
/// <para>
/// A retry is performed while at least one <see cref="TestResult"/> of the attempt has a
/// <see cref="UnitTestOutcome.Failed"/> or <see cref="UnitTestOutcome.Timeout"/> outcome. Retrying stops as
/// soon as an attempt produces no failed and no timed-out result. For a data-driven test (multiple results per
/// attempt) this is an aggregate rule: an <see cref="UnitTestOutcome.Inconclusive"/> result (for example when
/// the test calls <c>Assert.Inconclusive()</c>) only ends retrying when none of its sibling results in the same
/// attempt failed or timed out; otherwise the attempt is still retried. The last attempt's results become the
/// final outcome of the test.
/// </para>
/// <para>
/// This attribute retries within a single test-host run. It is independent from the
/// <c>Microsoft.Testing.Extensions.Retry</c> <c>--retry-failed-tests</c> orchestrator, which re-invokes the whole
/// test host. When both are enabled at the same time the attempt counts multiply (a test can run up to
/// <c>(MaxRetryAttempts + 1) x (--retry-failed-tests + 1)</c> times), so avoid combining them unless that is
/// intended.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, Inherited = false, AllowMultiple = false)]
public sealed class RetryAttribute : RetryBaseAttribute
{
    /// <summary>
    /// Initializes a new instance of the <see cref="RetryAttribute"/> class with the given number of max retries.
    /// </summary>
    /// <param name="maxRetryAttempts">The maximum number of retry attempts. This must be greater than or equal to 1.</param>
    public RetryAttribute(int maxRetryAttempts)
    {
#pragma warning disable CA1512 // Use ArgumentOutOfRangeException throw helper
        if (maxRetryAttempts < 1)
        {
            throw new ArgumentOutOfRangeException(nameof(maxRetryAttempts));
        }
#pragma warning restore CA1512 // Use ArgumentOutOfRangeException throw helper

        MaxRetryAttempts = maxRetryAttempts;
    }

    /// <summary>
    /// Gets the number of retries that the test should make in case of failures.
    /// Note that before RetryAttribute is considered, the test was already executed once.
    /// This property determines the max number of retries after the first normal run.
    /// </summary>
    public int MaxRetryAttempts { get; }

    /// <summary>
    /// Gets or sets the delay, in milliseconds, between retries.
    /// This delay is also applied after the first run and before the first retry attempt.
    /// </summary>
    public int MillisecondsDelayBetweenRetries { get; set; }

    /// <summary>
    /// Gets or sets the delay backoff type.
    /// </summary>
    public DelayBackoffType BackoffType
    {
        get;
        set
        {
            if (value is < DelayBackoffType.Constant or > DelayBackoffType.Exponential)
            {
                throw new ArgumentOutOfRangeException(nameof(value));
            }

            field = value;
        }
    }

    /// <summary>
    /// Retries the test method <see cref="MaxRetryAttempts"/> times in case of failure.
    /// Note that a first run of the method was already executed and failed before this method is called.
    /// </summary>
    /// <param name="retryContext">An object to encapsulate the state needed for retry execution.</param>
    /// <returns>
    /// Returns a <see cref="RetryResult"/> object that contains the results of all attempts. The last added
    /// element determines the test outcome; under Microsoft.Testing.Platform the earlier attempts are also
    /// reported, tagged as superseded, so tooling can surface the retry. The VSTest host receives only the
    /// final result.
    /// </returns>
    protected internal override async Task<RetryResult> ExecuteAsync(RetryContext retryContext)
    {
        var result = new RetryResult();
        int currentDelay = MillisecondsDelayBetweenRetries;
        for (int i = 0; i < MaxRetryAttempts; i++)
        {
            // The caller already executed the test once. So we need to do the delay here.
            await Task.Delay(currentDelay).ConfigureAwait(false);
            if (BackoffType == DelayBackoffType.Exponential)
            {
                currentDelay *= 2;
            }

            TestResult[] testResults = await retryContext.ExecuteTaskGetter().ConfigureAwait(false);
            result.AddResult(testResults);
            if (IsAcceptableResultForRetry(testResults))
            {
                break;
            }
        }

        return result;
    }
}
