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

#if NETFRAMEWORK

using Microsoft.VisualStudio.TestPlatform.MSTest.TestAdapter;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Deployment;
using Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Interface;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Microsoft.VisualStudio.TestPlatform.MSTestAdapter.PlatformServices.Utilities;

/// <summary>
/// Utilities for AppDomain.
/// </summary>
internal static class AppDomainUtilities
{
    private const string ObjectModelVersionBuiltAgainst = "11.0.0.0";

    private const string ObjectModelAssemblyName = "Microsoft.VisualStudio.TestPlatform.ObjectModel";

    private static readonly Version DefaultVersion = new();
    private static readonly Version Version45 = new("4.5");

    /// <summary>
    /// Resolves the loaded VSTest object-model assembly by simple name, so this AppDomain-wiring code does not
    /// need a compile-time reference to it. By the time these methods run (test source host setup during
    /// discovery/execution) the adapter has already loaded the object model into the current (parent) domain,
    /// so its identity — including any binding redirect in effect — matches what a direct type reference resolved to.
    /// </summary>
    /// <returns>The object-model assembly.</returns>
    private static Assembly GetObjectModelAssembly()
        => AppDomain.CurrentDomain.GetAssemblies().FirstOrDefault(a => string.Equals(a.GetName().Name, ObjectModelAssemblyName, StringComparison.Ordinal))
            ?? Assembly.Load(ObjectModelAssemblyName);

    /// <summary>
    /// Gets or sets the Xml Utilities instance.
    /// </summary>
    [field: AllowNull]
    [field: MaybeNull]
    [AllowNull]
    internal static XmlUtilities XmlUtilities
    {
        get => field ??= new XmlUtilities();
        set;
    }

    /// <summary>
    /// Set the target framework for app domain setup if target framework of dll is > 4.5.
    /// </summary>
    /// <param name="setup">AppdomainSetup for app domain creation.</param>
    /// <param name="frameworkVersionString">The target framework version of the test source.</param>
    [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "Reviewed. Suppression is OK here.")]
    [SuppressMessage("StyleCop.CSharp.NamingRules", "SA1305:FieldNamesMustNotUseHungarianNotation", Justification = "Reviewed. Suppression is OK here.")]
    internal static void SetAppDomainFrameworkVersionBasedOnTestSource(AppDomainSetup setup, string frameworkVersionString)
    {
        if (GetTargetFrameworkVersionFromVersionString(frameworkVersionString).CompareTo(Version45) > 0)
        {
            PropertyInfo? pInfo = typeof(AppDomainSetup).GetProperty(EngineConstants.TargetFrameworkName);
            pInfo?.SetValue(setup, frameworkVersionString, null);
        }
    }

    /// <summary>
    /// Get target framework version string from the given dll.
    /// </summary>
    /// <param name="testSourcePath">
    /// The path of the dll.
    /// </param>
    /// <returns>
    /// Framework string.
    /// </returns>
    /// <remarks>
    /// Components/E2E tests could be added to cover these scenarios.
    /// </remarks>
    [SuppressMessage("StyleCop.CSharp.DocumentationRules", "SA1650:ElementDocumentationMustBeSpelledCorrectly", Justification = "Reviewed. Suppression is OK here.")]
    internal static string GetTargetFrameworkVersionString(string testSourcePath)
    {
        AppDomainSetup appDomainSetup = new()
        {
            LoaderOptimization = LoaderOptimization.MultiDomainHost,
        };

        SetConfigurationFile(appDomainSetup, new DeploymentUtility().GetConfigFile(testSourcePath));

        if (!File.Exists(testSourcePath))
        {
            return string.Empty;
        }

        AppDomain? appDomain = null;

        try
        {
            appDomain = AppDomain.CreateDomain("Framework Version String Domain", null, appDomainSetup);

            // Wire the eqttrace logs in this domain to the current domain.
            PlatformServiceProvider.Instance.AdapterTraceLogger.SetupRemoteEqtTraceListeners(appDomain);

            // Add an assembly resolver to resolve ObjectModel or any Test Platform dependencies.
            // Not moving to IMetaDataImport APIs because the time taken for this operation is <20 ms and
            // IMetaDataImport needs COM registration which is not a guarantee in Dev15.
            Type assemblyResolverType = typeof(AssemblyResolver);

            var resolutionPaths = new List<string>
                {
                    Path.GetDirectoryName(GetObjectModelAssembly().Location),
                    Path.GetDirectoryName(testSourcePath),
                };

            CreateInstance(
                appDomain,
                assemblyResolverType,
                [resolutionPaths]);

            var assemblyLoadWorker =
                (AssemblyLoadWorker)CreateInstance(
                appDomain,
                typeof(AssemblyLoadWorker),
                null);

            string targetFramework = assemblyLoadWorker.GetTargetFrameworkVersionStringFromPath(testSourcePath, out string? errorMessage);

            if (errorMessage is not null)
            {
                PlatformServiceProvider.Instance.AdapterTraceLogger.Error(errorMessage);
            }

            return targetFramework;
        }
        catch (Exception exception)
        {
            if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsErrorEnabled)
            {
                PlatformServiceProvider.Instance.AdapterTraceLogger.Error(exception);
            }
        }
        finally
        {
            if (appDomain != null)
            {
                AppDomain.Unload(appDomain);
            }
        }

        return string.Empty;
    }

    /// <summary>
    /// Set configuration file on the parameter appDomain.
    /// </summary>
    /// <param name="appDomainSetup"> The app Domain Setup. </param>
    /// <param name="testSourceConfigFile"> The test Source Config File. </param>
    [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Requirement is to handle all kinds of user exceptions and message appropriately.")]
    internal static void SetConfigurationFile(AppDomainSetup appDomainSetup, string? testSourceConfigFile)
    {
        if (StringEx.IsNullOrEmpty(testSourceConfigFile))
        {
            // Use the current domains configuration setting.
            appDomainSetup.ConfigurationFile = AppDomain.CurrentDomain.SetupInformation.ConfigurationFile;
            return;
        }

        if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsInfoEnabled)
        {
            PlatformServiceProvider.Instance.AdapterTraceLogger.Info("UnitTestAdapter: Using configuration file {0} to setup appdomain for test source {1}.", testSourceConfigFile, Path.GetFileNameWithoutExtension(testSourceConfigFile));
        }

        appDomainSetup.ConfigurationFile = Path.GetFullPath(testSourceConfigFile);

        try
        {
            // Add redirection of the built 11.0 Object Model assembly to the current version if that is not 11.0
            string currentVersionOfObjectModel = GetObjectModelAssembly().GetName().Version.ToString();
            if (!string.Equals(currentVersionOfObjectModel, ObjectModelVersionBuiltAgainst, StringComparison.Ordinal))
            {
                AssemblyName assemblyName = GetObjectModelAssembly().GetName();
                byte[] configurationBytes =
                    XmlUtilities.AddAssemblyRedirection(
                        testSourceConfigFile,
                        assemblyName,
                        ObjectModelVersionBuiltAgainst,
                        assemblyName.Version.ToString());
                appDomainSetup.SetConfigurationBytes(configurationBytes);
            }
        }
        catch (Exception ex)
        {
            if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsErrorEnabled)
            {
                PlatformServiceProvider.Instance.AdapterTraceLogger.Error("Exception hit while adding binding redirects to test source config file. Exception : {0}", ex);
            }
        }
    }

    internal static object CreateInstance(AppDomain appDomain, Type type, object?[]? arguments)
    {
        string? typeAssemblyLocation = type.Assembly.Location;
        string? fullFilePath = typeAssemblyLocation == null ? null : Path.Combine(appDomain.SetupInformation.ApplicationBase, Path.GetFileName(typeAssemblyLocation));

        EnsureRelevantStaticStateIsRestored(appDomain);

        if (fullFilePath == null || File.Exists(fullFilePath))
        {
            // If the assembly exists in the app base directory, load it from there itself.
            // Even if it does not exist, Create the type in the default Load Context and let the CLR resolve the assembly path.
            // This would load the assembly in the Default Load context.
            return appDomain.CreateInstanceAndUnwrap(
                type.Assembly.FullName,
                type.FullName,
                false,
                BindingFlags.Default,
                null,
                arguments,
                null,
                null);
        }
        else
        {
            // This means that the file is not present in the app base directory. Load it from Path instead.
            // NOTE: We expect that all types that we are creating from here are types we know the location for.
            // This would load the assembly in the Load-From context.
            // While the above if condition is satisfied for most common cases, there could be a case where the adapter dlls
            // do not get copied over to where the test assembly is, in which case we load them from where the parent AppDomain is picking them up from.
            return appDomain.CreateInstanceFromAndUnwrap(
                typeAssemblyLocation,
                type.FullName,
                false,
                BindingFlags.Default,
                null,
                arguments,
                null,
                null);
        }
    }

    /// <summary>
    /// Get the Version for the target framework version string.
    /// </summary>
    /// <param name="version">Target framework string.</param>
    /// <returns>Framework Version.</returns>
    internal static Version GetTargetFrameworkVersionFromVersionString(string version)
    {
        try
        {
            if (version.Length > EngineConstants.DotNetFrameWorkStringPrefix.Length + 1)
            {
                string versionPart = version.Substring(EngineConstants.DotNetFrameWorkStringPrefix.Length + 1);
                return new Version(versionPart);
            }
        }
        catch (FormatException ex)
        {
            // if the version is ".NETPortable,Version=v4.5,Profile=Profile259", then above code will throw exception.
            PlatformServiceProvider.Instance.AdapterTraceLogger.Warning($"AppDomainUtilities.GetTargetFrameworkVersionFromVersionString: Could not create version object from version string '{version}' due to error '{ex.Message}':");
        }

        return DefaultVersion;
    }

    private static void EnsureRelevantStaticStateIsRestored(AppDomain appDomain)
    {
        // AppDomain is not preserving the state static (by-design, as it's for isolation).
        // However, there is some static state that we want to preserve, so we need to set it explicitly.
        Type staticStateHelperType = typeof(StaticStateHelper);
        var staticStateHelper = appDomain.CreateInstanceFromAndUnwrap(staticStateHelperType.Assembly.Location, staticStateHelperType.FullName) as StaticStateHelper;
        staticStateHelper?.SetUICulture(CultureInfo.DefaultThreadCurrentUICulture);

        try
        {
            staticStateHelper?.SetTrace(PlatformServiceProvider.Instance.AdapterTraceLogger);
        }
        catch (Exception ex)
        {
            // Extremely bad to catch exception this way, but this happens with MTP when deployment items are used.
            // This is likely because child app domain has a different directory and the assembly identity is considered different.
            // This then causes:
            // System.ArgumentException: Object type cannot be converted to target type.
            // Historically, we never moved the logger instance to the inner appdomain for MTP.
            // So ignoring for the niche scenario of MTP with deployment items is a better behavior than before.
            // Ideally, we should find a way to always propagate the logger correctly.
            if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsWarningEnabled)
            {
                PlatformServiceProvider.Instance.AdapterTraceLogger.Warning($"Failed to set trace logger '{PlatformServiceProvider.Instance.AdapterTraceLogger}' in app domain '{appDomain.FriendlyName}'. Error: {ex.Message}");
            }
        }
    }

    private sealed class StaticStateHelper : MarshalByRefObject
    {
#pragma warning disable CA1822 // Mark members as static - Should not be static for our need
        // The overloads of CreateInstanceAndUnwrap that takes the culture info are actually not setting the culture
        // of the AppDomain but only using this culture for the cast/conversion of the arguments.
        // For the problem reported by vendors, we would only need to set the DefaultThreadCurrentUICulture as it's
        // the culture we want to use for the resx.
        public void SetUICulture(CultureInfo uiCulture) => CultureInfo.DefaultThreadCurrentUICulture = uiCulture;

        public void SetTrace(ITraceLogger traceLogger) => PlatformServiceProvider.Instance.AdapterTraceLogger = traceLogger;
    }
}

#endif
