// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Shared;
using Microsoft.Build.Tasks;
using Microsoft.Build.Utilities;
using Shouldly;
using Xunit;
using Xunit.Abstractions;

#nullable disable

namespace Microsoft.Build.UnitTests.ResolveAssemblyReference_Tests
{
    /// <summary>
    /// Unit tests for the ResolveAssemblyReference task that involve, among other things, checking suggested redirects 
    /// </summary>
    public sealed class SuggestedRedirects : ResolveAssemblyReferenceTestFixture
    {
        public SuggestedRedirects(ITestOutputHelper output) : base(output)
        {
        }


        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1
        ///   References - B
        ///        Depends on D version 2
        ///
        /// And neither D1 nor D2 are CopyLocal = true. In this case, both dependencies
        /// are kept because this will work in a SxS manner.
        /// </summary>
        [Fact]
        public void ConflictBetweenNonCopyLocalDependencies()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("A"),
                new TaskItem("B")
            };

            t.SearchPaths = new string[]
            {
                s_myLibrariesRootPath,
                s_myLibraries_V1Path,
                s_myLibraries_V2Path
            };

            Execute(t);

            Assert.Equal(3, t.ResolvedDependencyFiles.Length);
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, s_myLibraries_V2_DDllPath));
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, s_myLibraries_V1_DDllPath));
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, s_myLibraries_V2_GDllPath));

            Assert.Single(t.SuggestedRedirects);
            Assert.True(ContainsItem(t.SuggestedRedirects, @"D, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa")); // "Expected to find suggested redirect, but didn't"
            Assert.Equal(1, e.Warnings); // "Should only be one warning for suggested redirects."
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1
        ///   References - B
        ///        Depends on D version 2
        ///
        /// And both D1 and D2 are CopyLocal = true. This case is a warning because both
        /// assemblies can't be copied to the output directory.
        /// </summary>
        [Fact]
        public void ConflictBetweenCopyLocalDependencies()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine engine = new MockEngine(_output);
            t.BuildEngine = engine;

            t.Assemblies = new ITaskItem[] {
                new TaskItem("A"), new TaskItem("B")
            };

            t.SearchPaths = new string[] {
                s_myLibrariesRootPath, s_myLibraries_V1Path, s_myLibraries_V2Path
            };

            t.TargetFrameworkDirectories = new string[] { s_myVersion20Path };

            bool result = Execute(t);

            Assert.Equal(1, engine.Warnings); // @"Expected a warning because this is an unresolvable conflict."
            Assert.Single(t.SuggestedRedirects);
            Assert.True(ContainsItem(t.SuggestedRedirects, @"D, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa")); // "Expected to find suggested redirect, but didn't"
            Assert.Equal(1, engine.Warnings); // "Should only be one warning for suggested redirects."
            Assert.Contains(
                String.Format(
                        AssemblyResources.GetString(
                            "ResolveAssemblyReference.ConflictRedirectSuggestion"),
                        "D, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa",
                        "1.0.0.0",
                        s_myLibraries_V1_DDllPath,
                        "2.0.0.0",
                        s_myLibraries_V2_DDllPath),
                engine.Log);
        }


        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1
        ///   References - B
        ///        Depends on D version 2
        ///
        /// And both D1 and D2 are CopyLocal = true. In this case, there is no warning because
        /// AutoUnify is set to true.
        /// </summary>
        [Fact]
        public void ConflictBetweenCopyLocalDependenciesWithAutoUnify()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine engine = new MockEngine(_output);
            t.BuildEngine = engine;
            t.AutoUnify = true;

            t.Assemblies = new ITaskItem[] {
                new TaskItem("A"), new TaskItem("B")
            };

            t.SearchPaths = new string[] {
                s_myLibrariesRootPath, s_myLibraries_V1Path, s_myLibraries_V2Path
            };

            t.TargetFrameworkDirectories = new string[] { s_myVersion20Path };

            bool result = Execute(t);

            // RAR will now produce suggested redirects even if AutoUnify is on.
            Assert.Single(t.SuggestedRedirects);
            Assert.Equal(0, engine.Warnings); // "Should be no warning for suggested redirects."
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1
        ///   References - B
        ///        Depends on D version 2
        ///   References - D, version 1
        ///
        /// Both D1 and D2 are CopyLocal. This is a warning because D1 is a lower version
        /// than D2 so that can't unify. These means that eventually when they're copied
        /// to the output directory they'll conflict.
        /// </summary>
        [Fact]
        public void ConflictWithBackVersionPrimary()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("B"),
                new TaskItem("A"),
                new TaskItem("D, Version=1.0.0.0, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa")
            };

            t.SearchPaths = new string[]
            {
                s_myLibrariesRootPath, s_myLibraries_V2Path, s_myLibraries_V1Path
            };

            t.TargetFrameworkDirectories = new string[] { s_myVersion20Path };

            bool result = Execute(t);

            Assert.Equal(1, e.Warnings); // @"Expected one warning."

            // Check that we have a message identifying conflicts with "D"
            string warningMessage = e.WarningEvents[0].Message;
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("ResolveAssemblyReference.FoundConflicts", "D", string.Empty));
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.ConflictFound", "D, Version=1.0.0.0, CulTUre=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa", "D, Version=2.0.0.0, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa"));
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.FourSpaceIndent", ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.ReferenceDependsOn", "D, Version=1.0.0.0, CulTUre=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa", Path.Combine(s_myLibraries_V1Path, "D.dll"))));

            Assert.Empty(t.SuggestedRedirects);
            Assert.Equal(3, t.ResolvedFiles.Length);
            Assert.True(ContainsItem(t.ResolvedFiles, s_myLibraries_V1_DDllPath)); // "Expected to find assembly, but didn't."
        }


        /// <summary>
        /// Same as ConflictWithBackVersionPrimary, except AutoUnify is true.
        /// Even when AutoUnify is set we should see a warning since the binder will not allow
        /// an older version to satisfy a reference to a newer version.
        /// </summary>
        [Fact]
        public void ConflictWithBackVersionPrimaryWithAutoUnify()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("B"),
                new TaskItem("A"),
                new TaskItem("D, Version=1.0.0.0, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa")
            };

            t.AutoUnify = true;

            t.SearchPaths = new string[]
            {
                s_myLibrariesRootPath, s_myLibraries_V2Path, s_myLibraries_V1Path
            };

            t.TargetFrameworkDirectories = new string[] { s_myVersion20Path };

            bool result = Execute(t);

            Assert.Equal(1, e.Warnings); // @"Expected one warning."

            // Check that we have a message identifying conflicts with "D"
            string warningMessage = e.WarningEvents[0].Message;
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringStripCodeAndKeyword("ResolveAssemblyReference.FoundConflicts", "D", string.Empty));
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.ConflictFound", "D, Version=1.0.0.0, CulTUre=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa", "D, Version=2.0.0.0, Culture=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa"));
            warningMessage.ShouldContain(ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.FourSpaceIndent", ResourceUtilities.FormatResourceStringIgnoreCodeAndKeyword("ResolveAssemblyReference.ReferenceDependsOn", "D, Version=1.0.0.0, CulTUre=neutral, PublicKeyToken=aaaaaaaaaaaaaaaa", Path.Combine(s_myLibraries_V1Path, "D.dll"))));

            Assert.Empty(t.SuggestedRedirects);
            Assert.Equal(3, t.ResolvedFiles.Length);
            Assert.True(ContainsItem(t.ResolvedFiles, s_myLibraries_V1_DDllPath)); // "Expected to find assembly, but didn't."
        }


        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - Microsoft.Office.Interop.Excel
        ///        Depends on Office, Version=12.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c
        ///
        ///   References - MS.Internal.Test.Automation.Office.Excel
        ///        Depends on Office, Version=12.0.0.0, Culture=neutral, PublicKeyToken=94de0004b6e3fcc5
        ///
        /// Notice that the two primaries have dependencies that only differ by PKT. Suggested redirects should
        /// only happen if the two assemblies differ by nothing but version.
        /// </summary>
        [Fact]
        public void Regress313747_FalseSuggestedRedirectsWhenAssembliesDifferOnlyByPkt()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("Microsoft.Office.Interop.Excel"),
                new TaskItem("MS.Internal.Test.Automation.Office.Excel")
            };

            t.SearchPaths = new string[]
            {
                @"c:\Regress313747",
            };

            Execute(t);

            Assert.Empty(t.SuggestedRedirects);
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// (1) Primary reference A v 2.0.0.0 is found.
        /// (2) Primary reference B is found.
        /// (3) Primary reference B depends on A v 1.0.0.0
        /// (4) Dependency A v 1.0.0.0 is not found.
        /// (5) App.Config does not contain a binding redirect from A v 1.0.0.0 -> 2.0.0.0
        ///
        /// We need to warn and suggest an app.config entry because the runtime environment will require a binding
        /// redirect to function. Without a binding redirect, loading B will cause A.V1 to try to load. It won't be
        /// there and there won't be a binding redirect to point it at 2.0.0.0.
        /// </summary>
        [Fact]
        [Trait("Category", "netcore-osx-failing")]
        [Trait("Category", "netcore-linux-failing")]
        public void Regress442570_MissingBackVersionShouldWarn()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("A"),
                new TaskItem("B")
            };

            t.SearchPaths = new string[]
            {
                @"c:\Regress442570",
            };

            Execute(t);

            // Expect a suggested redirect plus a warning
            Assert.Single(t.SuggestedRedirects);
            Assert.Equal(1, e.Warnings);
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// (1) Primary reference A v 2.0.0.0 is found and marked externally resolved.
        /// (2) Primary reference B is externally resolved and depends on A v 1.0.0.0
        ///
        /// When AutoGenerateBindingRedirects is used, we need to find the redirect
        /// in the externally resolved graph.
        /// </summary>
        [Fact]
        public void RedirectsAreSuggestedInExternallyResolvedGraph()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            // NB: These are what common targets would set when AutoGenerateBindingRedirects is enabled.
            t.AutoUnify = true;
            t.FindDependenciesOfExternallyResolvedReferences = true;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("A", new Dictionary<string, string> { ["ExternallyResolved"] = "true" }),
                new TaskItem("B", new Dictionary<string, string> { ["ExternallyResolved"] = "true" }),
            };

            t.SearchPaths = new string[]
            {
                s_regress442570_RootPath
            };

            Execute(t);

            // Expect a suggested redirect with no warning.
            Assert.Single(t.SuggestedRedirects);
            Assert.Equal(0, e.Warnings);
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1 (but PKT=null)
        ///   References - B
        ///        Depends on D version 2 (but PKT=null)
        ///
        /// There should be no suggested redirect because only strongly named assemblies can have
        /// binding redirects.
        /// </summary>
        [Fact]
        [Trait("Category", "netcore-osx-failing")]
        [Trait("Category", "netcore-linux-failing")]
        public void Regress387218_UnificationRequiresStrongName()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("A"),
                new TaskItem("B")
            };

            t.SearchPaths = new string[]
            {
                @"c:\Regress387218",
                @"c:\Regress387218\v1",
                @"c:\Regress387218\v2"
            };

            Execute(t);

            Assert.Equal(2, t.ResolvedDependencyFiles.Length);
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, @"c:\Regress387218\v2\D.dll")); // "Expected to find assembly, but didn't."
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, @"c:\Regress387218\v1\D.dll")); // "Expected to find assembly, but didn't."
            Assert.Empty(t.SuggestedRedirects);
            Assert.Equal(0, e.Warnings); // "Should only be no warning about suggested redirects."
        }

        /// <summary>
        /// Consider this dependency chain:
        ///
        /// App
        ///   References - A
        ///        Depends on D version 1 (but Culture=fr)
        ///   References - B
        ///        Depends on D version 2 (but Culture=en)
        ///
        /// There should be no suggested redirect because assemblies with different cultures cannot unify.
        /// </summary>
        [Fact]
        [Trait("Category", "netcore-osx-failing")]
        [Trait("Category", "netcore-linux-failing")]
        public void Regress390219_UnificationRequiresSameCulture()
        {
            ResolveAssemblyReference t = new ResolveAssemblyReference();

            MockEngine e = new MockEngine(_output);
            t.BuildEngine = e;

            t.Assemblies = new ITaskItem[]
            {
                new TaskItem("A"),
                new TaskItem("B")
            };

            t.SearchPaths = new string[]
            {
                @"c:\Regress390219",
                @"c:\Regress390219\v1",
                @"c:\Regress390219\v2"
            };

            Execute(t);

            Assert.Equal(2, t.ResolvedDependencyFiles.Length);
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, @"c:\Regress390219\v2\D.dll")); // "Expected to find assembly, but didn't."
            Assert.True(ContainsItem(t.ResolvedDependencyFiles, @"c:\Regress390219\v1\D.dll")); // "Expected to find assembly, but didn't."
            Assert.Empty(t.SuggestedRedirects);
            Assert.Equal(0, e.Warnings); // "Should only be no warning about suggested redirects."
        }

        // It is hard to write a test that will fail with the root cause of https://github.com/dotnet/msbuild/issues/4002
        // because it requires reading real files from disk and we mock GetAssemblyName in RAR tests.
        //
        // So to add some coverage, simply check that we get a Culture=neutral when enumerating references of this test
        // assembly.
        [Fact]
        public void RealGetAssemblyNameIncludesCulture()
        {
            using (var info = new AssemblyInformation(Assembly.GetExecutingAssembly().Location))
            {
                foreach (var dependency in info.Dependencies)
                {
                    Assert.Contains("Culture=neutral", dependency.ToString());
                }
            }
        }
    }
}
