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

#if !WINDOWS_UWP && !WIN_UI

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

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

internal abstract class DeploymentUtilityBase
{
    protected const string TestAssemblyConfigFileExtension = ".config";
    protected const string NetAppConfigFile = "App.Config";

    /// <summary>
    /// Prefix for deployment folder to avoid confusions with other folders (like trx attachments).
    /// </summary>
    protected const string DeploymentFolderPrefix = "Deploy";

    public DeploymentUtilityBase()
        : this(new DeploymentItemUtility(PlatformServiceProvider.Instance.ReflectionOperations), new AssemblyUtility(), new FileUtility())
    {
    }

    public DeploymentUtilityBase(DeploymentItemUtility deploymentItemUtility, AssemblyUtility assemblyUtility, FileUtility fileUtility)
    {
        DeploymentItemUtility = deploymentItemUtility;
        AssemblyUtility = assemblyUtility;
        FileUtility = fileUtility;
    }

    protected FileUtility FileUtility { get; set; }

    protected DeploymentItemUtility DeploymentItemUtility { get; set; }

    protected AssemblyUtility AssemblyUtility { get; set; }

    public bool Deploy(IEnumerable<UnitTestElement> tests, string source, DeploymentContext deploymentContext, IAdapterMessageLogger messageLogger, TestRunDirectories runDirectories)
    {
        IList<DeploymentItem> deploymentItems = DeploymentItemUtility.GetDeploymentItems(tests);

        // we just deploy source if there are no deployment items for current source but there are deployment items for other sources
        return Deploy(source, deploymentContext, messageLogger, deploymentItems, runDirectories);
    }

    /// <summary>
    /// Create deployment directories.
    /// </summary>
    /// <param name="deploymentContext">The host deployment inputs.</param>
    /// <param name="firstTestSource">
    /// The path to the test assembly of the first test case. In most cases, all
    /// test cases belong to the same assembly, but not guaranteed. We are using the path from
    /// the first test case as a "best effort" implementation. DeploymentItem isn't correctly designed and should be deprecated in future.
    /// </param>
    /// <returns>TestRunDirectories instance.</returns>
    public TestRunDirectories CreateDeploymentDirectories(DeploymentContext deploymentContext, string? firstTestSource)
    {
        string resultsDirectory = GetTestResultsDirectory(deploymentContext);
        string rootDeploymentDirectory = GetRootDeploymentDirectory(resultsDirectory);

#if NETFRAMEWORK
        bool isAppDomainCreationDisabled = deploymentContext.RunSettingsXml is not null && MSTestAdapterSettings.IsAppDomainCreationDisabled(deploymentContext.RunSettingsXml);
#else
        // AppDomains are only supported in .NET Framework.
        const bool isAppDomainCreationDisabled = true;
#endif

        var result = new TestRunDirectories(rootDeploymentDirectory, firstTestSource, isAppDomainCreationDisabled);

        FileUtility.CreateDirectoryIfNotExists(rootDeploymentDirectory);
        FileUtility.CreateDirectoryIfNotExists(result.InDirectory);
        FileUtility.CreateDirectoryIfNotExists(result.OutDirectory);
        FileUtility.CreateDirectoryIfNotExists(result.InMachineNameDirectory);

        return result;
    }

    /// <summary>
    /// add deployment items based on MSTestSettingsProvider.Settings.DeployTestSourceDependencies. This property is ignored in net core.
    /// </summary>
    /// <param name="testSourceHandler">The test source.</param>
    /// <param name="deploymentItems">Deployment Items.</param>
    /// <param name="warnings">Warnings.</param>
    public abstract void AddDeploymentItemsBasedOnMsTestSetting(string testSourceHandler, IList<DeploymentItem> deploymentItems, List<string> warnings);

    /// <summary>
    /// Get the parent test results directory where deployment will be done.
    /// </summary>
    /// <param name="deploymentContext">The host deployment inputs.</param>
    /// <returns>The test results directory.</returns>
    public static string GetTestResultsDirectory(DeploymentContext deploymentContext) => !StringEx.IsNullOrEmpty(deploymentContext.TestRunDirectory)
            ? deploymentContext.TestRunDirectory
            : Path.GetFullPath(Path.Combine(Path.GetTempPath(), TestRunDirectories.DefaultDeploymentRootDirectory));

    /// <summary>
    /// Get root deployment directory.
    /// </summary>
    /// <param name="baseDirectory">The base directory.</param>
    /// <returns>Root deployment directory.</returns>
    public abstract string GetRootDeploymentDirectory(string baseDirectory);

    internal string? GetConfigFile(string testSourceHandler)
    {
        string? configFile = null;

        string assemblyConfigFile = testSourceHandler + TestAssemblyConfigFileExtension;
        if (FileUtility.DoesFileExist(assemblyConfigFile))
        {
            // Path to config file cannot be bad: storage is already checked, and extension is valid.
            configFile = testSourceHandler + TestAssemblyConfigFileExtension;
        }
        else
        {
            string netAppConfigFile = Path.Combine(Path.GetDirectoryName(testSourceHandler)!, NetAppConfigFile);
            if (FileUtility.DoesFileExist(netAppConfigFile))
            {
                configFile = netAppConfigFile;
            }
        }

        return configFile;
    }

    /// <summary>
    /// Does the deployment of parameter deployment items and the testSourceHandler to the parameter directory.
    /// </summary>
    /// <param name="deploymentItems">The deployment item.</param>
    /// <param name="testSourceHandler">The test source.</param>
    /// <param name="deploymentDirectory">The deployment directory.</param>
    /// <param name="resultsDirectory">Root results directory.</param>
    /// <returns>Returns a list of deployment warnings.</returns>
    protected IEnumerable<string> Deploy(IList<DeploymentItem> deploymentItems, string testSourceHandler, string deploymentDirectory, string resultsDirectory)
    {
        Ensure.NotNullOrWhiteSpace(deploymentDirectory);
        Ensure.NotNullOrWhiteSpace(testSourceHandler);
        ApplicationStateGuard.Ensure(FileUtility.DoesDirectoryExist(deploymentDirectory), $"Deployment directory {deploymentDirectory} does not exist");
        ApplicationStateGuard.Ensure(FileUtility.DoesFileExist(testSourceHandler), $"TestSource {testSourceHandler} does not exist.");

        testSourceHandler = Path.GetFullPath(testSourceHandler);
        var warnings = new List<string>();

        AddDeploymentItemsBasedOnMsTestSetting(testSourceHandler, deploymentItems, warnings);

        // Maps relative to Out dir destination -> source and used to determine if there are conflicted items.
        var destToSource = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        // Copy the deployment items. (As deployment item can correspond to directories as well, so each deployment item may map to n files)
        foreach (DeploymentItem deploymentItem in deploymentItems)
        {
            // Validate the output directory.
            if (!IsOutputDirectoryValid(deploymentItem, deploymentDirectory, warnings))
            {
                continue;
            }

            // Get the files corresponding to this deployment item
            List<string>? deploymentItemFiles = GetFullPathToFilesCorrespondingToDeploymentItem(deploymentItem, testSourceHandler, resultsDirectory, warnings, out bool itemIsDirectory);
            if (deploymentItemFiles == null)
            {
                continue;
            }

            string fullPathToDeploymentItemSource = GetFullPathToDeploymentItemSource(deploymentItem.SourcePath, testSourceHandler);

            // Note: source is already rooted.
            foreach (string deploymentItemFile in deploymentItemFiles)
            {
                DebugEx.Assert(Path.IsPathRooted(deploymentItemFile), "File " + deploymentItemFile + " is not rooted");

                // List of files to deploy, by default, just itemFile.
                var filesToDeploy = new List<string>(1)
                {
                    deploymentItemFile,
                };

                // Find dependencies of test deployment items and deploy them at the same time as the main file.
                if (deploymentItem.OriginType == DeploymentItemOriginType.PerTestDeployment &&
                    AssemblyUtility.IsAssemblyExtension(Path.GetExtension(deploymentItemFile)))
                {
                    AddDependenciesOfDeploymentItem(deploymentItemFile, filesToDeploy, warnings);
                }

                foreach (string fileToDeploy in filesToDeploy)
                {
                    DebugEx.Assert(Path.IsPathRooted(fileToDeploy), $"File {fileToDeploy} is not rooted");

                    // Ignore the test platform files.
                    string tempFile = Path.GetFileName(fileToDeploy);
                    // Use AssemblyFileLocator to safely obtain the file name even when Assembly.Location
                    // is empty (single-file / Native AOT scenarios) by falling back to the simple name.
                    string assemblyName = AssemblyFileLocator.GetFileNameOrSimpleName(GetType().Assembly);
                    if (tempFile.Equals(assemblyName, StringComparison.OrdinalIgnoreCase))
                    {
                        continue;
                    }

                    string relativeDestination;
                    if (itemIsDirectory)
                    {
                        // Deploy into subdirectory of deployment (Out) dir.
                        DebugEx.Assert(fileToDeploy.StartsWith(fullPathToDeploymentItemSource, StringComparison.Ordinal), "Somehow source is outside original dir.");
                        relativeDestination = FileUtility.TryConvertPathToRelative(fileToDeploy, fullPathToDeploymentItemSource);
                    }
                    else
                    {
                        // Deploy just to the deployment (Out) dir.
                        relativeDestination = Path.GetFileName(fileToDeploy);
                    }

                    relativeDestination = Path.Combine(deploymentItem.RelativeOutputDirectory, relativeDestination);  // Ignores empty arg1.
                    string destination = Path.Combine(deploymentDirectory, relativeDestination);
                    try
                    {
                        destination = Path.GetFullPath(destination);
                    }
                    catch (Exception e)
                    {
                        string warning = string.Format(CultureInfo.CurrentCulture, Resource.DeploymentErrorFailedToAccessFile, destination, e.GetType(), e.Message);
                        warnings.Add(warning);

                        continue;
                    }

                    if (!destToSource.TryGetValue(relativeDestination, out string? value))
                    {
                        destToSource.Add(relativeDestination, fileToDeploy);
                        if (fileToDeploy == destination)
                        {
                            continue;
                        }

                        // Now, finally we can copy the file...
                        destination = FileUtility.CopyFileOverwrite(fileToDeploy, destination, out string? warning);
                        if (!StringEx.IsNullOrEmpty(warning))
                        {
                            warnings.Add(warning);
                        }

                        if (StringEx.IsNullOrEmpty(destination))
                        {
                            continue;
                        }

                        // We clear the attributes so that e.g. you can write to the copies of files originally under SCC.
                        FileUtility.SetAttributes(destination, FileAttributes.Normal);

                        // Deploy PDB for line number info in stack trace.
                        FileUtility.FindAndDeployPdb(destination, relativeDestination, fileToDeploy, destToSource);
                    }
                    else if (!string.Equals(fileToDeploy, value, StringComparison.OrdinalIgnoreCase))
                    {
                        if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsWarningEnabled)
                        {
                            PlatformServiceProvider.Instance.AdapterTraceLogger.Warning(
                                "Conflict during copying file: '{0}' and '{1}' are from different origins although they might be the same.",
                                fileToDeploy,
                                value);
                        }
                    }
                } // foreach fileToDeploy.
            } // foreach itemFile.
        }

        return warnings;
    }

    // Find dependencies of test deployment items
    protected abstract void AddDependenciesOfDeploymentItem(string deploymentItemFile, IList<string> filesToDeploy, IList<string> warnings);

    /// <summary>
    /// Get files corresponding to parameter deployment item.
    /// </summary>
    /// <param name="deploymentItem">Deployment Item.</param>
    /// <param name="testSourceHandler">The test source.</param>
    /// <param name="resultsDirectory">Results directory which should be skipped for deployment.</param>
    /// <param name="warnings">Warnings.</param>
    /// <param name="isDirectory">Is this a directory.</param>
    /// <returns>Paths to items to deploy.</returns>
    [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Requirement is to handle all kinds of user exceptions and message appropriately.")]
    protected List<string>? GetFullPathToFilesCorrespondingToDeploymentItem(DeploymentItem deploymentItem, string testSourceHandler, string resultsDirectory, IList<string> warnings, out bool isDirectory)
    {
        DebugEx.Assert(deploymentItem != null, "deploymentItem should not be null.");
        DebugEx.Assert(!StringEx.IsNullOrEmpty(testSourceHandler), "testSourceHandler should not be null or empty.");

        try
        {
            isDirectory = IsDeploymentItemSourceADirectory(deploymentItem, testSourceHandler, out string? directory);
            if (isDirectory)
            {
                return FileUtility.AddFilesFromDirectory(
                    directory!,
                    deployDirectory => string.Equals(deployDirectory, resultsDirectory, StringComparison.OrdinalIgnoreCase), false);
            }

            if (IsDeploymentItemSourceAFile(deploymentItem.SourcePath, testSourceHandler, out string fileName))
            {
                return [fileName];
            }

            // If file/directory is not found, then try removing the prefix and see if it is present.
            string fileOrDirNameOnly = Path.GetFileName(deploymentItem.SourcePath);
            if (IsDeploymentItemSourceAFile(fileOrDirNameOnly, testSourceHandler, out fileName))
            {
                return [fileName];
            }

            string message = string.Format(CultureInfo.CurrentCulture, Resource.CannotFindFile, fileName);
            throw new FileNotFoundException(message, fileName);
        }
        catch (Exception e)
        {
            warnings.Add(string.Format(
                CultureInfo.CurrentCulture, Resource.DeploymentErrorFailedToGetFileForDeploymentItem, deploymentItem, e.GetType(), e.Message));
        }

        isDirectory = false;
        return null;
    }

    protected static string GetFullPathToDeploymentItemSource(string deploymentItemSourcePath, string testSourceHandler) => Path.IsPathRooted(deploymentItemSourcePath)
            ? deploymentItemSourcePath
            : Path.Combine(Path.GetDirectoryName(testSourceHandler)!, deploymentItemSourcePath);

    /// <summary>
    /// Validate the output directory for the parameter deployment item.
    /// </summary>
    /// <param name="deploymentItem">The deployment item.</param>
    /// <param name="deploymentDirectory">The deployment directory.</param>
    /// <param name="warnings">Warnings.</param>
    /// <returns>True if valid.</returns>
    [SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Requirement is to handle all kinds of user exceptions and message appropriately.")]
    protected static bool IsOutputDirectoryValid(DeploymentItem deploymentItem, string deploymentDirectory, IList<string> warnings)
    {
        DebugEx.Assert(deploymentItem != null, "deploymentItem should not be null.");
        DebugEx.Assert(!StringEx.IsNullOrEmpty(deploymentDirectory), "deploymentDirectory should not be null or empty.");
        DebugEx.Assert(warnings != null, "warnings should not be null.");

        // Check that item.output dir does not go outside deployment Out dir, otherwise you can erase any file!
        string outputDir = deploymentDirectory;
        try
        {
            outputDir = Path.GetFullPath(Path.Combine(deploymentDirectory, deploymentItem.RelativeOutputDirectory));

            // convert the short path to full length path (like joe~1.dom to joe.domain) and the comparison
            // startsWith in the next loop will work for the matching paths.
            deploymentDirectory = Path.GetFullPath(deploymentDirectory);
        }
        catch (Exception e)
        {
            string warning = string.Format(
                CultureInfo.CurrentCulture,
                Resource.DeploymentErrorFailedToAccesOutputDirectory,
                deploymentItem.SourcePath,
                outputDir,
                e.GetType(),
                e.GetExceptionMessage());

            warnings.Add(warning);
            return false;
        }

        if (!outputDir.StartsWith(deploymentDirectory, StringComparison.OrdinalIgnoreCase))
        {
            string warning = string.Format(
                CultureInfo.CurrentCulture,
                Resource.DeploymentErrorBadDeploymentItem,
                deploymentItem.SourcePath,
                deploymentItem.RelativeOutputDirectory);
            warnings.Add(warning);

            return false;
        }

        return true;
    }

    protected string? AddTestSourceConfigFileIfExists(string testSourceHandler, IList<DeploymentItem> deploymentItems)
    {
        string? configFile = GetConfigFile(testSourceHandler);

        if (!StringEx.IsNullOrEmpty(configFile))
        {
            DeploymentItemUtility.AddDeploymentItem(deploymentItems, new DeploymentItem(configFile));
        }

        return configFile;
    }

    /// <summary>
    /// Log the parameter warnings on the parameter logger.
    /// </summary>
    /// <param name="messageLogger">Message logger.</param>
    /// <param name="warnings">Warnings.</param>
    private static void LogWarnings(IAdapterMessageLogger messageLogger, IEnumerable<string> warnings)
    {
        DebugEx.Assert(messageLogger != null, "Logger should not be null");

        // log the warnings
        foreach (string warning in warnings)
        {
            messageLogger.SendMessage(MessageLevel.Warning, warning);
        }
    }

    private bool Deploy(string source, DeploymentContext deploymentContext, IAdapterMessageLogger messageLogger, IList<DeploymentItem> deploymentItems, TestRunDirectories runDirectories)
    {
        if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsInfoEnabled)
        {
            PlatformServiceProvider.Instance.AdapterTraceLogger.Info("MSTestExecutor: Found that deployment items for source {0} are: ", source);
            foreach (DeploymentItem item in deploymentItems)
            {
                PlatformServiceProvider.Instance.AdapterTraceLogger.Info("MSTestExecutor: SourcePath: - {0}", item.SourcePath);
            }
        }

        // Do the deployment.
        if (PlatformServiceProvider.Instance.AdapterTraceLogger.IsInfoEnabled)
        {
            PlatformServiceProvider.Instance.AdapterTraceLogger.Info("MSTestExecutor: Using deployment directory {0} for source {1}.", runDirectories.OutDirectory, source);
        }

        IEnumerable<string> warnings = Deploy([.. deploymentItems], source, runDirectories.OutDirectory, GetTestResultsDirectory(deploymentContext));

        // Log warnings
        LogWarnings(messageLogger, warnings);
        return deploymentItems is { Count: > 0 };
    }

    private bool IsDeploymentItemSourceAFile(string deploymentItemSourcePath, string testSourceHandler, out string file)
    {
        file = GetFullPathToDeploymentItemSource(deploymentItemSourcePath, testSourceHandler);

        return FileUtility.DoesFileExist(file);
    }

    private bool IsDeploymentItemSourceADirectory(DeploymentItem deploymentItem, string testSourceHandler, [NotNullWhen(true)] out string? resultDirectory)
    {
        resultDirectory = null;

        string directory = GetFullPathToDeploymentItemSource(deploymentItem.SourcePath, testSourceHandler);
        directory = directory.TrimEnd('/', '\\');

        if (FileUtility.DoesDirectoryExist(directory))
        {
            resultDirectory = directory;
            return true;
        }

        return false;
    }
}
#endif
