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

using Microsoft.Testing.Extensions.MSBuild.Serializers;
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.Messages;
using Microsoft.Testing.Platform.Extensions.TestHost;
using Microsoft.Testing.Platform.Helpers;
using Microsoft.Testing.Platform.IPC.Models;
using Microsoft.Testing.Platform.Services;

namespace Microsoft.Testing.Extensions.MSBuild;

[UnsupportedOSPlatform("browser")]
internal sealed class MSBuildConsumer : IDataConsumer, ITestSessionLifetimeHandler
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ICommandLineOptions _commandLineOptions;
    private readonly Stopwatch _sessionStopwatch = new();
    private MSBuildTestApplicationLifecycleCallbacks? _msBuildTestApplicationLifecycleCallbacks;
    private bool _sessionEnded;
    private int _totalTests;
    private int _totalFailedTests;
    private int _totalPassedTests;
    private int _totalSkippedTests;

    public MSBuildConsumer(IServiceProvider serviceProvider, ICommandLineOptions commandLineOptions)
    {
        _serviceProvider = serviceProvider;
        _commandLineOptions = commandLineOptions;
    }

    public Type[] DataTypesConsumed { get; } =
    [
        typeof(TestNodeUpdateMessage),
    ];

    public string Uid => nameof(MSBuildConsumer);

    public string Version => ExtensionVersion.DefaultSemVer;

    public string DisplayName => nameof(MSBuildConsumer);

    public string Description => Resources.ExtensionResources.MSBuildExtensionsDescription;

    public Task<bool> IsEnabledAsync()
        => Task.FromResult(_commandLineOptions.IsOptionSet(MSBuildConstants.MSBuildNodeOptionKey));

    public Task OnTestSessionStartingAsync(ITestSessionContext testSessionContext)
    {
        // We get the pipe from the MSBuildTestApplicationLifecycleCallbacks only if we're enabled.
        _msBuildTestApplicationLifecycleCallbacks = _serviceProvider.GetRequiredService<MSBuildTestApplicationLifecycleCallbacks>();
        _sessionStopwatch.Restart();
        return Task.CompletedTask;
    }

    public async Task OnTestSessionFinishingAsync(ITestSessionContext testSessionContext)
    {
        _sessionStopwatch.Stop();
        _sessionEnded = true;

        await HandleSummaryAsync(_sessionStopwatch.Elapsed, testSessionContext.CancellationToken).ConfigureAwait(false);
    }

    public async Task ConsumeAsync(IDataProducer dataProducer, IData value, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        // Avoid processing messages if the session has ended.
        if (_sessionEnded)
        {
            return;
        }

        if (value is not TestNodeUpdateMessage testNodeStateChanged)
        {
            return;
        }

        // Collect required properties in a single zero-allocation GetStructEnumerator() pass.
        // TestNodeStateProperty is an O(1) cached field on PropertyBag (see SingleOrDefault) so
        // only the TimingProperty/TestFileLocationProperty lookups actually walk the linked list;
        // we replace those 2 walks with 1 here. We also preserve PropertyBag.SingleOrDefault's
        // throw-on-duplicate invariant for those two property types so malformed messages still
        // fail fast instead of silently dropping later instances.
        TestNodeStateProperty? stateProperty = null;
        TimingProperty? timingProperty = null;
        TestFileLocationProperty? testFileLocationProperty = null;
        AssertionFailureProperty? assertionFailureProperty = null;

        PropertyBag.PropertyBagEnumerator enumerator = testNodeStateChanged.TestNode.Properties.GetStructEnumerator();
        while (enumerator.MoveNext())
        {
            switch (enumerator.Current)
            {
                case TestNodeStateProperty s when stateProperty is null:
                    stateProperty = s;
                    break;
                case TimingProperty t:
                    if (timingProperty is not null)
                    {
                        throw new InvalidOperationException($"Found multiple properties of type '{typeof(TimingProperty)}'.");
                    }

                    timingProperty = t;
                    break;
                case TestFileLocationProperty f:
                    if (testFileLocationProperty is not null)
                    {
                        throw new InvalidOperationException($"Found multiple properties of type '{typeof(TestFileLocationProperty)}'.");
                    }

                    testFileLocationProperty = f;
                    break;
                case AssertionFailureProperty a:
                    if (assertionFailureProperty is not null)
                    {
                        throw new InvalidOperationException($"Found multiple properties of type '{typeof(AssertionFailureProperty)}'.");
                    }

                    assertionFailureProperty = a;
                    break;
            }
        }

        // A test framework that retries in-process reports every attempt under the same test node uid. A
        // superseded attempt is not the test's outcome, so logging it would emit an MSBuild error for a [Retry]
        // test that goes on to pass - and fail the build.
        if (testNodeStateChanged.TestNode.IsSupersededRetryAttempt())
        {
            return;
        }

        switch (stateProperty)
        {
            case ErrorTestNodeStateProperty errorState:
                await HandleFailuresAsync(
                    testNodeStateChanged.TestNode.DisplayName,
                    isCanceled: false,
                    duration: timingProperty is null ? null : ToHumanReadableDuration(timingProperty.GlobalTiming.Duration.TotalMilliseconds),
                    errorMessage: errorState.Exception?.Message ?? errorState.Explanation,
                    errorStackTrace: errorState.Exception?.StackTrace,
                    expected: null,
                    actual: null,
                    testFileLocationProperty?.FilePath,
                    testFileLocationProperty?.LineSpan.Start.Line ?? 0,
                    cancellationToken).ConfigureAwait(false);
                break;

            case FailedTestNodeStateProperty failedState:
                await HandleFailuresAsync(
                    testNodeStateChanged.TestNode.DisplayName,
                    isCanceled: false,
                    duration: timingProperty is null ? null : ToHumanReadableDuration(timingProperty.GlobalTiming.Duration.TotalMilliseconds),
                    errorMessage: failedState.Exception?.Message ?? failedState.Explanation,
                    errorStackTrace: failedState.Exception?.StackTrace,
                    expected: assertionFailureProperty is not null ? assertionFailureProperty.Expected : failedState.Exception?.Data["assert.expected"] as string,
                    actual: assertionFailureProperty is not null ? assertionFailureProperty.Actual : failedState.Exception?.Data["assert.actual"] as string,
                    testFileLocationProperty?.FilePath,
                    testFileLocationProperty?.LineSpan.Start.Line ?? 0,
                    cancellationToken).ConfigureAwait(false);
                break;

            case TimeoutTestNodeStateProperty timeoutState:
                await HandleFailuresAsync(
                    testNodeStateChanged.TestNode.DisplayName,
                    isCanceled: true,
                    duration: timingProperty is null ? null : ToHumanReadableDuration(timingProperty.GlobalTiming.Duration.TotalMilliseconds),
                    errorMessage: timeoutState.Exception?.Message ?? timeoutState.Explanation,
                    errorStackTrace: timeoutState.Exception?.StackTrace,
                    expected: null,
                    actual: null,
                    testFileLocationProperty?.FilePath,
                    testFileLocationProperty?.LineSpan.Start.Line ?? 0,
                    cancellationToken).ConfigureAwait(false);
                break;

#pragma warning disable CS0618, MTP0001 // Type or member is obsolete
            case CancelledTestNodeStateProperty canceledState:
#pragma warning restore CS0618, MTP0001 // Type or member is obsolete
                await HandleFailuresAsync(
                    testNodeStateChanged.TestNode.DisplayName,
                    isCanceled: true,
                    duration: timingProperty is null ? null : ToHumanReadableDuration(timingProperty.GlobalTiming.Duration.TotalMilliseconds),
                    errorMessage: canceledState.Exception?.Message ?? canceledState.Explanation,
                    errorStackTrace: canceledState.Exception?.StackTrace,
                    expected: null,
                    actual: null,
                    testFileLocationProperty?.FilePath,
                    testFileLocationProperty?.LineSpan.Start.Line ?? 0,
                    cancellationToken).ConfigureAwait(false);
                break;

            case PassedTestNodeStateProperty:
                _totalTests++;
                _totalPassedTests++;
                break;

            case SkippedTestNodeStateProperty:
                _totalTests++;
                _totalSkippedTests++;
                break;
        }
    }

    private async Task HandleFailuresAsync(string testDisplayName, bool isCanceled, string? duration, string? errorMessage, string? errorStackTrace, string? expected, string? actual, string? codeFilePath, int lineNumber, CancellationToken cancellationToken)
    {
        _totalTests++;
        _totalFailedTests++;
        ApplicationStateGuard.Ensure(_msBuildTestApplicationLifecycleCallbacks is not null);
        ApplicationStateGuard.Ensure(_msBuildTestApplicationLifecycleCallbacks.PipeClient is not null);
        var failedTestInfoRequest = new FailedTestInfoRequest(testDisplayName, isCanceled, duration, errorMessage, errorStackTrace, expected, actual, codeFilePath, lineNumber);
        await _msBuildTestApplicationLifecycleCallbacks.PipeClient.RequestReplyAsync<FailedTestInfoRequest, VoidResponse>(failedTestInfoRequest, cancellationToken).ConfigureAwait(false);
    }

    private async Task HandleSummaryAsync(TimeSpan elapsed, CancellationToken cancellationToken)
    {
        string? duration = ToHumanReadableDuration(elapsed.TotalMilliseconds);

        ApplicationStateGuard.Ensure(_msBuildTestApplicationLifecycleCallbacks is not null);
        ApplicationStateGuard.Ensure(_msBuildTestApplicationLifecycleCallbacks.PipeClient is not null);
        bool allowSkipped = PlatformCommandLineProvider.GetZeroTestsPolicy(_commandLineOptions) == ZeroTestsPolicy.AllowSkipped;
        var runSummaryInfoRequest = new RunSummaryInfoRequest(_totalTests, _totalFailedTests, _totalPassedTests, _totalSkippedTests, duration, allowSkipped);
        await _msBuildTestApplicationLifecycleCallbacks.PipeClient.RequestReplyAsync<RunSummaryInfoRequest, VoidResponse>(runSummaryInfoRequest, cancellationToken).ConfigureAwait(false);
    }

    private static string? ToHumanReadableDuration(double? durationInMs)
    {
        if (durationInMs is null or < 0)
        {
            return null;
        }

        var time = TimeSpan.FromMilliseconds(durationInMs.Value);

        StringBuilder stringBuilder = new();
        bool hasParentValue = false;

        if (time.Days > 0)
        {
            stringBuilder.Append(CultureInfo.InvariantCulture, $"{time.Days}d");
            hasParentValue = true;
        }

        if (time.Hours > 0 || hasParentValue)
        {
            stringBuilder.Append(CultureInfo.InvariantCulture, $"{(hasParentValue ? " " : string.Empty)}{(hasParentValue ? time.Hours.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0') : time.Hours.ToString(CultureInfo.InvariantCulture))}h");
            hasParentValue = true;
        }

        if (time.Minutes > 0 || hasParentValue)
        {
            stringBuilder.Append(CultureInfo.InvariantCulture, $"{(hasParentValue ? " " : string.Empty)}{(hasParentValue ? time.Minutes.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0') : time.Minutes.ToString(CultureInfo.InvariantCulture))}m");
            hasParentValue = true;
        }

        if (time.Seconds > 0 || hasParentValue)
        {
            stringBuilder.Append(CultureInfo.InvariantCulture, $"{(hasParentValue ? " " : string.Empty)}{(hasParentValue ? time.Seconds.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0') : time.Seconds.ToString(CultureInfo.InvariantCulture))}s");
            hasParentValue = true;
        }

        if (time.Milliseconds >= 0 || hasParentValue)
        {
            stringBuilder.Append(CultureInfo.InvariantCulture, $"{(hasParentValue ? " " : string.Empty)}{(hasParentValue ? time.Milliseconds.ToString(CultureInfo.InvariantCulture).PadLeft(3, '0') : time.Milliseconds.ToString(CultureInfo.InvariantCulture))}ms");
        }

        return stringBuilder.ToString();
    }
}
