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

using AwesomeAssertions;

using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.Execution;
using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter.ObjectModel;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices;

using Moq;

using TestFramework.ForTestingMSTest;

namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution;

public class TestAssemblyInfoTests : TestContainer
{
    private readonly TestAssemblyInfo _testAssemblyInfo;

    private readonly MethodInfo _dummyMethodInfo;

    private readonly TestContext _testContext;

    public TestAssemblyInfoTests()
    {
        _testAssemblyInfo = new TestAssemblyInfo(typeof(TestAssemblyInfoTests).Assembly);
        _dummyMethodInfo = typeof(TestAssemblyInfoTests).GetMethods().First();

        var testContext = new Mock<TestContext>();
        testContext.SetupGet(x => x.CancellationTokenSource).Returns(new CancellationTokenSource());
        _testContext = testContext.Object;
    }

    public void TestAssemblyInfoAssemblyInitializeMethodThrowsForMultipleAssemblyInitializeMethods()
    {
        void Action()
        {
            _testAssemblyInfo.AssemblyInitializeMethod = _dummyMethodInfo;
            _testAssemblyInfo.AssemblyInitializeMethod = _dummyMethodInfo;
        }

        new Action(Action).Should().Throw<TypeInspectionException>();
    }

    public void TestAssemblyInfoAssemblyCleanupMethodThrowsForMultipleAssemblyCleanupMethods()
    {
        void Action()
        {
            _testAssemblyInfo.AssemblyCleanupMethod = _dummyMethodInfo;
            _testAssemblyInfo.AssemblyCleanupMethod = _dummyMethodInfo;
        }

        new Action(Action).Should().Throw<TypeInspectionException>();
    }

    public void TestAssemblyHasExecutableCleanupMethodShouldReturnFalseIfAssemblyHasNoCleanupMethod() => _testAssemblyInfo.HasExecutableCleanupMethod.Should().BeFalse();

    public void TestAssemblyHasExecutableCleanupMethodShouldReturnTrueEvenIfAssemblyInitializationThrewAnException()
    {
        _testAssemblyInfo.AssemblyCleanupMethod = _dummyMethodInfo;
        _testAssemblyInfo.AssemblyInitializationException = new TestFailedException(UnitTestOutcome.Error, "ERROR");

        _testAssemblyInfo.HasExecutableCleanupMethod.Should().BeTrue();
    }

    public void TestAssemblyHasExecutableCleanupMethodShouldReturnTrueIfAssemblyCleanupMethodIsAvailable()
    {
        _testAssemblyInfo.AssemblyCleanupMethod = _dummyMethodInfo;

        _testAssemblyInfo.HasExecutableCleanupMethod.Should().BeTrue();
    }

    #region Run Assembly Initialize tests

    public async Task RunAssemblyInitializeShouldNotInvokeIfAssemblyInitializeIsNull()
    {
        int assemblyInitCallCount = 0;
        DummyTestClass.AssemblyInitializeMethodBody = _ => assemblyInitCallCount++;

        _testAssemblyInfo.AssemblyInitializeMethod = null;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(null!);

        assemblyInitCallCount.Should().Be(0);
        result.Outcome.Should().Be(UnitTestOutcome.Passed);
    }

    public async Task RunAssemblyInitializeShouldNotExecuteAssemblyInitializeIfItHasAlreadyExecuted()
    {
        int assemblyInitCallCount = 0;
        DummyTestClass.AssemblyInitializeMethodBody = _ => assemblyInitCallCount++;

        _testAssemblyInfo.IsAssemblyInitializeExecuted = true;
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext);

        assemblyInitCallCount.Should().Be(0);
        result.Outcome.Should().Be(UnitTestOutcome.Passed);
    }

    public async Task RunAssemblyInitializeShouldExecuteAssemblyInitialize()
    {
        int assemblyInitCallCount = 0;
        DummyTestClass.AssemblyInitializeMethodBody = _ => assemblyInitCallCount++;
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext);

        assemblyInitCallCount.Should().Be(1);
        result.Outcome.Should().Be(UnitTestOutcome.Passed);
    }

    public async Task RunAssemblyInitializeShouldSetAssemblyInitializeExecutedFlag()
    {
        DummyTestClass.AssemblyInitializeMethodBody = _ => { };

        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext);

        _testAssemblyInfo.IsAssemblyInitializeExecuted.Should().BeTrue();
        result.Outcome.Should().Be(UnitTestOutcome.Passed);
    }

    public async Task RunAssemblyInitializeShouldSetAssemblyInitializationExceptionOnException()
    {
#pragma warning disable RS0030 // Do not use banned APIs
        DummyTestClass.AssemblyInitializeMethodBody = _ => Assert.Inconclusive("Test Inconclusive");
#pragma warning restore RS0030 // Do not use banned APIs
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestResult testResult = await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext);
        testResult.Should().NotBeNull();
        testResult.TestFailureException.Should().NotBeNull();
        _testAssemblyInfo.AssemblyInitializationException.Should().NotBeNull();
    }

    public async Task RunAssemblyInitializeShouldThrowTestFailedExceptionOnAssertionFailure()
    {
#pragma warning disable RS0030 // Do not use banned APIs
        DummyTestClass.AssemblyInitializeMethodBody = tc => Assert.Fail("Test failure");
#pragma warning restore RS0030 // Do not use banned APIs
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        var exception = (await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext)).TestFailureException as TestFailedException;
        exception.Should().NotBeNull();
        exception.Outcome.Should().Be(UnitTestOutcome.Failed);
        exception.Message.Should().Be($"Assembly Initialization method Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests+DummyTestClass.AssemblyInitializeMethod threw exception. Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException: Assertion failed.{Environment.NewLine}Test failure. Aborting test execution.");
        exception.StackTraceInformation!.ErrorStackTrace.Should().Contain(
            "Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests.DummyTestClass.AssemblyInitializeMethod");
        exception.InnerException.Should().BeOfType<AssertFailedException>();
    }

    public async Task RunAssemblyInitializeShouldThrowTestFailedExceptionWithInconclusiveOnAssertInconclusive()
    {
#pragma warning disable RS0030 // Do not use banned APIs
        DummyTestClass.AssemblyInitializeMethodBody = tc => Assert.Inconclusive("Test Inconclusive");
#pragma warning restore RS0030 // Do not use banned APIs
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        var exception = (await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext)).TestFailureException as TestFailedException;
        exception.Should().NotBeNull();
        exception.Outcome.Should().Be(UnitTestOutcome.Inconclusive);
        exception.Message.Should().Be("Assembly Initialization method Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests+DummyTestClass.AssemblyInitializeMethod threw exception. Microsoft.VisualStudio.TestTools.UnitTesting.AssertInconclusiveException: Assert.Inconclusive. Test Inconclusive. Aborting test execution.");
        exception.StackTraceInformation!.ErrorStackTrace.Should().Contain(
            "Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests.DummyTestClass.AssemblyInitializeMethod");
        exception.InnerException.Should().BeOfType<AssertInconclusiveException>();
    }

    public async Task RunAssemblyInitializeShouldThrowTestFailedExceptionWithNonAssertExceptions()
    {
        DummyTestClass.AssemblyInitializeMethodBody = tc => throw new ArgumentException("Some actualErrorMessage message", new InvalidOperationException("Inner actualErrorMessage message"));
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        var exception = (await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext)).TestFailureException as TestFailedException;
        exception.Should().NotBeNull();
        exception.Outcome.Should().Be(UnitTestOutcome.Failed);
        exception.Message.Should().Be("Assembly Initialization method Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests+DummyTestClass.AssemblyInitializeMethod threw exception. System.ArgumentException: Some actualErrorMessage message. Aborting test execution.");
        exception.StackTraceInformation!.ErrorStackTrace.Should().Contain(
            "Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests.DummyTestClass.AssemblyInitializeMethod");
        exception.InnerException.Should().BeOfType<ArgumentException>();
        exception.InnerException.InnerException.Should().BeOfType<InvalidOperationException>();
    }

    public async Task RunAssemblyInitializeShouldThrowTheInnerMostExceptionWhenThereAreMultipleNestedTypeInitializationExceptions()
    {
        DummyTestClass.AssemblyInitializeMethodBody = tc =>
            // This helper calls inner helper, and the inner helper ctor throws.
            // We want to see the real exception on screen, and not TypeInitializationException
            // which has no info about what failed.
            FailingStaticHelper.DoWork();
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        var exception = (await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext)).TestFailureException as TestFailedException;
        exception.Should().NotBeNull();
        exception.Outcome.Should().Be(UnitTestOutcome.Failed);
        exception.Message.Should().Be("Assembly Initialization method Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests+DummyTestClass.AssemblyInitializeMethod threw exception. System.InvalidOperationException: I fail.. Aborting test execution.");
        exception.StackTraceInformation!.ErrorStackTrace.StartsWith(
            "   at Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests.FailingStaticHelper..cctor()", StringComparison.Ordinal).Should().BeTrue();
        exception.InnerException.Should().BeOfType<InvalidOperationException>();
    }

    public async Task RunAssemblyInitializeShouldThrowForAlreadyExecutedTestAssemblyInitWithException()
    {
        DummyTestClass.AssemblyInitializeMethodBody = _ => { };
        _testAssemblyInfo.IsAssemblyInitializeExecuted = true;
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;
        _testAssemblyInfo.AssemblyInitializationException = new TestFailedException(UnitTestOutcome.Failed, "Cached Test failure");

        var exception = (await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext)).TestFailureException as TestFailedException;
        exception.Should().NotBeNull();
        exception.Outcome.Should().Be(UnitTestOutcome.Failed);
        exception.Message.Should().Be("Cached Test failure");
    }

    public async Task RunAssemblyInitializeShouldPassOnTheTestContextToAssemblyInitMethod()
    {
        bool hasExecuted = false;
        DummyTestClass.AssemblyInitializeMethodBody = tc =>
        {
            (tc == _testContext).Should().BeTrue();
            hasExecuted = true;
        };
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(_testContext);
        hasExecuted.Should().BeTrue();
        result.Outcome.Should().Be(UnitTestOutcome.Passed);
    }

    public async Task RunAssemblyInitializeShouldCapturePostAssemblyInitPropertiesOnSuccess()
    {
        DummyTestClass.AssemblyInitializeMethodBody = tc =>
        {
            var context = (TestContext)tc;
            context.Properties["AssemblyInitKey"] = "AssemblyInitValue";
            context.Properties["AnotherKey"] = 42;
        };
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestContextImplementation testContext = GetTestContext();
        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext);

        result.Outcome.Should().Be(UnitTestOutcome.Passed);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().NotBeNull();
        _testAssemblyInfo.PostAssemblyInitProperties!.Should().ContainKey("AssemblyInitKey");
        _testAssemblyInfo.PostAssemblyInitProperties["AssemblyInitKey"].Should().Be("AssemblyInitValue");
        _testAssemblyInfo.PostAssemblyInitProperties["AnotherKey"].Should().Be(42);
    }

    public async Task RunAssemblyInitializeShouldExcludePerContextLabelsFromPostAssemblyInitProperties()
    {
        DummyTestClass.AssemblyInitializeMethodBody = tc => ((TestContext)tc).Properties["UserKey"] = "UserValue";
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        // Use a context with a class name so the FullyQualifiedTestClassName label is present.
        TestContextImplementation testContext = new(null, "Dummy.ClassName", new Dictionary<string, object?>(), null, null);
        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext);

        result.Outcome.Should().Be(UnitTestOutcome.Passed);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().NotBeNull();
        _testAssemblyInfo.PostAssemblyInitProperties!.Should().NotContainKey(TestContext.FullyQualifiedTestClassNameLabel);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().NotContainKey(TestContext.TestNameLabel);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().ContainKey("UserKey");
    }

    public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullWhenAssemblyInitSetsNoProperties()
    {
        // AssemblyInitialize exists and succeeds but sets no TestContext.Properties.
        DummyTestClass.AssemblyInitializeMethodBody = _ => { };
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestContextImplementation testContext = GetTestContext();
        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext);

        result.Outcome.Should().Be(UnitTestOutcome.Passed);

        // CaptureLifecycleProperties returns null when no non-label properties were set,
        // so MergeProperties can short-circuit without acquiring a lock on each test's context.
        _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull();
    }

    public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullWhenAssemblyInitMethodIsNull()
    {
        _testAssemblyInfo.AssemblyInitializeMethod = null;

        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(null!);

        result.Outcome.Should().Be(UnitTestOutcome.Passed);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull();
    }

    public async Task RunAssemblyInitializeShouldLeavePostAssemblyInitPropertiesNullOnFailure()
    {
        DummyTestClass.AssemblyInitializeMethodBody = tc =>
        {
            ((TestContext)tc).Properties["AssemblyInitKey"] = "AssemblyInitValue";
            throw new InvalidOperationException("boom");
        };
        _testAssemblyInfo.AssemblyInitializeMethod = typeof(DummyTestClass).GetMethod("AssemblyInitializeMethod")!;

        TestContextImplementation testContext = GetTestContext();
        TestResult result = await _testAssemblyInfo.RunAssemblyInitializeAsync(testContext);

        result.Outcome.Should().NotBe(UnitTestOutcome.Passed);
        _testAssemblyInfo.PostAssemblyInitProperties.Should().BeNull();
    }

    #endregion

    #region Run Assembly Cleanup tests

    public async Task RunAssemblyCleanupShouldNotInvokeIfAssemblyCleanupIsNull()
    {
        int assemblyCleanupCallCount = 0;
        DummyTestClass.AssemblyCleanupMethodBody = () => assemblyCleanupCallCount++;

        _testAssemblyInfo.AssemblyCleanupMethod = null;

        (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext())).Should().BeNull();
        assemblyCleanupCallCount.Should().Be(0);
    }

    public async Task RunAssemblyCleanupShouldInvokeIfAssemblyCleanupMethod()
    {
        int assemblyCleanupCallCount = 0;
        DummyTestClass.AssemblyCleanupMethodBody = () => assemblyCleanupCallCount++;

        _testAssemblyInfo.AssemblyCleanupMethod = typeof(DummyTestClass).GetMethod("AssemblyCleanupMethod")!;

        (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext())).Should().BeNull();
        assemblyCleanupCallCount.Should().Be(1);
    }

    public async Task RunAssemblyCleanupShouldReturnAssertFailureExceptionDetails()
    {
#pragma warning disable RS0030 // Do not use banned APIs
        DummyTestClass.AssemblyCleanupMethodBody = () => Assert.Fail("Test Failure.");
#pragma warning restore RS0030 // Do not use banned APIs

        _testAssemblyInfo.AssemblyCleanupMethod = typeof(DummyTestClass).GetMethod("AssemblyCleanupMethod")!;
        string? actualErrorMessage = (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext()))?.Message;
        actualErrorMessage!.StartsWith(
            $"Assembly Cleanup method DummyTestClass.AssemblyCleanupMethod failed. Error Message: Assertion failed.{Environment.NewLine}Test Failure..", StringComparison.Ordinal).Should().BeTrue($"Value: {actualErrorMessage}");
    }

    public async Task RunAssemblyCleanupShouldReturnAssertInconclusiveExceptionDetails()
    {
#pragma warning disable RS0030 // Do not use banned APIs
        DummyTestClass.AssemblyCleanupMethodBody = () => Assert.Inconclusive("Test Inconclusive.");
#pragma warning restore RS0030 // Do not use banned APIs

        _testAssemblyInfo.AssemblyCleanupMethod = typeof(DummyTestClass).GetMethod("AssemblyCleanupMethod")!;
        string? actualErrorMessage = (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext()))?.Message;
        actualErrorMessage!.StartsWith(
            "Assembly Cleanup method DummyTestClass.AssemblyCleanupMethod failed. Error Message: Assert.Inconclusive. Test Inconclusive..", StringComparison.Ordinal).Should().BeTrue($"Value: {actualErrorMessage}");
    }

    public async Task RunAssemblyCleanupShouldReturnExceptionDetailsOfNonAssertExceptions()
    {
        DummyTestClass.AssemblyCleanupMethodBody = () => throw new ArgumentException("Argument Exception");

        _testAssemblyInfo.AssemblyCleanupMethod = typeof(DummyTestClass).GetMethod("AssemblyCleanupMethod")!;
        string? actualErrorMessage = (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext()))?.Message;
        actualErrorMessage!.StartsWith(
            "Assembly Cleanup method DummyTestClass.AssemblyCleanupMethod failed. Error Message: System.ArgumentException: Argument Exception.", StringComparison.Ordinal).Should().BeTrue($"Value: {actualErrorMessage}");
    }

    public async Task RunAssemblyCleanupShouldThrowTheInnerMostExceptionWhenThereAreMultipleNestedTypeInitializationExceptions()
    {
        // This helper calls inner helper, and the inner helper ctor throws.
        // We want to see the real exception on screen, and not TypeInitializationException
        // which has no info about what failed.
        DummyTestClass.AssemblyCleanupMethodBody = FailingStaticHelper.DoWork;
        _testAssemblyInfo.AssemblyCleanupMethod = typeof(DummyTestClass).GetMethod("AssemblyCleanupMethod")!;

        string actualErrorMessage = (await _testAssemblyInfo.ExecuteAssemblyCleanupAsync(GetTestContext()))!.Message;

        actualErrorMessage.StartsWith("Assembly Cleanup method DummyTestClass.AssemblyCleanupMethod failed. Error Message: System.InvalidOperationException: I fail.. StackTrace:", StringComparison.Ordinal).Should().BeTrue();
        actualErrorMessage.Contains("at Microsoft.VisualStudio.TestPlatform.MSTestAdapter.UnitTests.Execution.TestAssemblyInfoTests.FailingStaticHelper..cctor()").Should().BeTrue();
    }

    private static TestContextImplementation GetTestContext()
        => new(null, null, new Dictionary<string, object?>(), null, null);

    #endregion

    [TestClass]
    public class DummyTestClass
    {
        public static Action<object> AssemblyInitializeMethodBody { get; set; } = null!;

        public static Action AssemblyCleanupMethodBody { get; set; } = null!;

        public TestContext TestContext { get; set; } = null!;

        // On .NET Framework, the JIT aggressively inlines small methods in Release mode,
        // which removes stack frames. NoInlining ensures the method appears in stack traces.
#if NETFRAMEWORK
        [MethodImpl(MethodImplOptions.NoInlining)]
#endif
        public static void AssemblyInitializeMethod(TestContext testContext) => AssemblyInitializeMethodBody.Invoke(testContext);

#if NETFRAMEWORK
        [MethodImpl(MethodImplOptions.NoInlining)]
#endif
        public static void AssemblyCleanupMethod() => AssemblyCleanupMethodBody.Invoke();
    }

    private static class FailingStaticHelper
    {
        static FailingStaticHelper() => throw new InvalidOperationException("I fail.");

        public static void DoWork()
        {
        }
    }

    private static class FailingInnerStaticHelper
    {
        static FailingInnerStaticHelper() => throw new InvalidOperationException("I fail.");

        public static void Initialize()
        {
        }
    }
}
