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

using System.Diagnostics;
using Microsoft.NET.Sdk.Razor.Tool.CommandLineUtils;

namespace Microsoft.NET.Sdk.Razor.Tool
{
    internal static class ServerConnection
    {
        private const string ServerName = "rzc.dll";

        // Spend up to 1s connecting to existing process (existing processes should be always responsive).
        private const int TimeOutMsExistingProcess = 1000;

        // Spend up to 20s connecting to a new process, to allow time for it to start.
        private const int TimeOutMsNewProcess = 20000;

        // Custom delegate that contains an out param to use with TryCreateServerCore method.
        private delegate TResult TryCreateServerCoreDelegate<T1, T2, T3, T4, out TResult>(T1 arg1, T2 arg2, out T3 arg3, T4 arg4);

        public static bool WasServerMutexOpen(string mutexName)
        {
            Mutex mutex = null;
            var open = false;
            try
            {
                open = Mutex.TryOpenExisting(mutexName, out mutex);
            }
            catch
            {
                // In the case an exception occurred trying to open the Mutex then
                // the assumption is that it's not open.
            }

            mutex?.Dispose();

            return open;
        }

        /// <summary>
        /// Gets the value of the temporary path for the current environment assuming the working directory
        /// is <paramref name="workingDir"/>.  This function must emulate <see cref="Path.GetTempPath"/> as
        /// closely as possible.
        /// </summary>
        public static string GetTempPath(string workingDir)
        {
            if (PlatformInformation.IsUnix)
            {
                // Unix temp path is fine: it does not use the working directory
                // (it uses ${TMPDIR} if set, otherwise, it returns /tmp)
                return Path.GetTempPath();
            }

            var tmp = Environment.GetEnvironmentVariable("TMP");
            if (Path.IsPathRooted(tmp))
            {
                return tmp;
            }

            var temp = Environment.GetEnvironmentVariable("TEMP");
            if (Path.IsPathRooted(temp))
            {
                return temp;
            }

            if (!string.IsNullOrEmpty(workingDir))
            {
                if (!string.IsNullOrEmpty(tmp))
                {
                    return Path.Combine(workingDir, tmp);
                }

                if (!string.IsNullOrEmpty(temp))
                {
                    return Path.Combine(workingDir, temp);
                }
            }

            var userProfile = Environment.GetEnvironmentVariable("USERPROFILE");
            if (Path.IsPathRooted(userProfile))
            {
                return userProfile;
            }

            return Environment.GetEnvironmentVariable("SYSTEMROOT");
        }

        public static Task<ServerResponse> RunOnServer(
            string pipeName,
            IList<string> arguments,
            ServerPaths serverPaths,
            CancellationToken cancellationToken,
            string keepAlive = null,
            bool debug = false)
        {
            if (string.IsNullOrEmpty(pipeName))
            {
                pipeName = PipeName.ComputeDefault(serverPaths.ClientDirectory);
            }

            return RunOnServerCore(
                arguments,
                serverPaths,
                pipeName: pipeName,
                keepAlive: keepAlive,
                timeoutOverride: null,
                tryCreateServerFunc: TryCreateServerCore,
                cancellationToken: cancellationToken,
                debug: debug);
        }

        private static async Task<ServerResponse> RunOnServerCore(
            IList<string> arguments,
            ServerPaths serverPaths,
            string pipeName,
            string keepAlive,
            int? timeoutOverride,
            TryCreateServerCoreDelegate<string, string, int?, bool, bool> tryCreateServerFunc,
            CancellationToken cancellationToken,
            bool debug)
        {
            if (pipeName == null)
            {
                return new RejectedServerResponse();
            }

            if (serverPaths.TempDirectory == null)
            {
                return new RejectedServerResponse();
            }

            var clientDir = serverPaths.ClientDirectory;
            var timeoutNewProcess = timeoutOverride ?? TimeOutMsNewProcess;
            var timeoutExistingProcess = timeoutOverride ?? TimeOutMsExistingProcess;
            var clientMutexName = MutexName.GetClientMutexName(pipeName);
            Task<Client> pipeTask = null;

            Mutex clientMutex = null;
            var holdsMutex = false;

            try
            {
                try
                {
                    clientMutex = new Mutex(initiallyOwned: true, name: clientMutexName, createdNew: out holdsMutex);
                }
                catch (Exception ex)
                {
                    // The Mutex constructor can throw in certain cases. One specific example is docker containers
                    // where the /tmp directory is restricted. In those cases there is no reliable way to execute
                    // the server and we need to fall back to the command line.
                    // Example: https://github.com/dotnet/roslyn/issues/24124

                    ServerLogger.LogException(ex, "Client mutex creation failed.");

                    return new RejectedServerResponse();
                }

                if (!holdsMutex)
                {
                    try
                    {
                        holdsMutex = clientMutex.WaitOne(timeoutNewProcess);

                        if (!holdsMutex)
                        {
                            return new RejectedServerResponse();
                        }
                    }
                    catch (AbandonedMutexException)
                    {
                        holdsMutex = true;
                    }
                }

                // Check for an already running server
                var serverMutexName = MutexName.GetServerMutexName(pipeName);
                var wasServerRunning = WasServerMutexOpen(serverMutexName);
                var timeout = wasServerRunning ? timeoutExistingProcess : timeoutNewProcess;

                if (wasServerRunning || tryCreateServerFunc(clientDir, pipeName, out var _, debug))
                {
                    pipeTask = Client.ConnectAsync(pipeName, TimeSpan.FromMilliseconds(timeout), cancellationToken);
                }
            }
            finally
            {
                if (holdsMutex)
                {
                    clientMutex?.ReleaseMutex();
                }

                clientMutex?.Dispose();
            }

            if (pipeTask != null)
            {
                var client = await pipeTask.ConfigureAwait(false);
                if (client != null)
                {
                    var request = ServerRequest.Create(
                        serverPaths.WorkingDirectory,
                        serverPaths.TempDirectory,
                        arguments,
                        keepAlive);

                    return await TryProcessRequest(client, request, cancellationToken).ConfigureAwait(false);
                }
            }

            return new RejectedServerResponse();
        }

        /// <summary>
        /// Try to process the request using the server. Returns a null-containing Task if a response
        /// from the server cannot be retrieved.
        /// </summary>
        private static async Task<ServerResponse> TryProcessRequest(
            Client client,
            ServerRequest request,
            CancellationToken cancellationToken)
        {
            ServerResponse response;
            using (client)
            {
                // Write the request
                try
                {
                    ServerLogger.Log("Begin writing request");
                    await request.WriteAsync(client.Stream, cancellationToken).ConfigureAwait(false);
                    ServerLogger.Log("End writing request");
                }
                catch (Exception e)
                {
                    ServerLogger.LogException(e, "Error writing build request.");
                    return new RejectedServerResponse();
                }

                // Wait for the compilation and a monitor to detect if the server disconnects
                var serverCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);

                ServerLogger.Log("Begin reading response");

                var responseTask = ServerResponse.ReadAsync(client.Stream, serverCts.Token);
                var monitorTask = client.WaitForDisconnectAsync(serverCts.Token);
                await Task.WhenAny(new[] { responseTask, monitorTask }).ConfigureAwait(false);

                ServerLogger.Log("End reading response");

                if (responseTask.IsCompleted)
                {
                    // await the task to log any exceptions
                    try
                    {
                        response = await responseTask.ConfigureAwait(false);
                    }
                    catch (Exception e)
                    {
                        ServerLogger.LogException(e, "Error reading response");
                        response = new RejectedServerResponse();
                    }
                }
                else
                {
                    ServerLogger.Log("Server disconnect");
                    response = new RejectedServerResponse();
                }

                // Cancel whatever task is still around
                serverCts.Cancel();
                Debug.Assert(response != null);
                return response;
            }
        }

        private static string FindDotNetExecutable()
        {
            var expectedPath = Environment.GetEnvironmentVariable("DOTNET_HOST_PATH");
            if (!string.IsNullOrEmpty(expectedPath))
            {
                return expectedPath;
            }

#if NET
            expectedPath = System.Environment.ProcessPath;
#else
            expectedPath = Process.GetCurrentProcess().MainModule.FileName;
#endif

            if ("dotnet".Equals(Path.GetFileNameWithoutExtension(expectedPath), StringComparison.Ordinal))
            {
                return expectedPath;
            }

            // We were probably running from Visual Studio or Build Tools and found MSBuild instead of dotnet. Use the PATH...
            var paths = Environment.GetEnvironmentVariable("PATH").Split(Path.PathSeparator);
            var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
            foreach (string path in paths)
            {
                var dotnetPath = Path.Combine(path, exeName);
                if (File.Exists(dotnetPath))
                {
                    return dotnetPath;
                }
            }

            return exeName;
        }

        // Internal for testing.
        internal static bool TryCreateServerCore(string clientDir, string pipeName, out int? processId, bool debug = false)
        {
            processId = null;

            // The server should be in the same directory as the client
            var expectedCompilerPath = Path.Combine(clientDir, ServerName);

            var expectedPath = FindDotNetExecutable();

            var argumentList = new string[]
            {
                expectedCompilerPath,
                debug ? "--debug" : "",
                "server",
                "-p",
                pipeName
            };
            var processArguments = ArgumentEscaper.EscapeAndConcatenate(argumentList);

            if (!File.Exists(expectedCompilerPath))
            {
                return false;
            }

            if (PlatformInformation.IsWindows)
            {
                // Currently, there isn't a way to use the Process class to create a process without
                // inheriting handles(stdin/stdout/stderr) from its parent. This might cause the parent process
                // to block on those handles. So we use P/Invoke. This code was taken from MSBuild task starting code.
                // The work to customize this behavior is being tracked by https://github.com/dotnet/corefx/issues/306.

                var startInfo = new STARTUPINFO();
                startInfo.cb = Marshal.SizeOf(startInfo);
                startInfo.hStdError = NativeMethods.InvalidIntPtr;
                startInfo.hStdInput = NativeMethods.InvalidIntPtr;
                startInfo.hStdOutput = NativeMethods.InvalidIntPtr;
                startInfo.dwFlags = NativeMethods.STARTF_USESTDHANDLES;
                var dwCreationFlags = NativeMethods.NORMAL_PRIORITY_CLASS | NativeMethods.CREATE_NO_WINDOW;

                ServerLogger.Log("Attempting to create process '{0}'", expectedPath);

                var builder = new StringBuilder($@"""{expectedPath}"" {processArguments}");

                var success = NativeMethods.CreateProcess(
                    lpApplicationName: null,
                    lpCommandLine: builder,
                    lpProcessAttributes: NativeMethods.NullPtr,
                    lpThreadAttributes: NativeMethods.NullPtr,
                    bInheritHandles: false,
                    dwCreationFlags: dwCreationFlags,
                    lpEnvironment: NativeMethods.NullPtr, // Inherit environment
                    lpCurrentDirectory: clientDir,
                    lpStartupInfo: ref startInfo,
                    lpProcessInformation: out var processInfo);

                if (success)
                {
                    ServerLogger.Log("Successfully created process with process id {0}", processInfo.dwProcessId);
                    NativeMethods.CloseHandle(processInfo.hProcess);
                    NativeMethods.CloseHandle(processInfo.hThread);
                    processId = processInfo.dwProcessId;
                }
                else
                {
                    ServerLogger.Log("Failed to create process. GetLastError={0}", Marshal.GetLastWin32Error());
                }
                return success;
            }
            else
            {
                try
                {
                    var startInfo = new ProcessStartInfo()
                    {
                        FileName = expectedPath,
                        Arguments = processArguments,
                        UseShellExecute = false,
                        WorkingDirectory = clientDir,
                        RedirectStandardInput = true,
                        RedirectStandardOutput = true,
                        RedirectStandardError = true,
                        CreateNoWindow = true
                    };

                    var process = Process.Start(startInfo);
                    processId = process.Id;

                    return true;
                }
                catch
                {
                    return false;
                }
            }
        }
    }

    /// <summary>
    /// This class provides simple properties for determining whether the current platform is Windows or Unix-based.
    /// We intentionally do not use System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(...) because
    /// it incorrectly reports 'true' for 'Windows' in desktop builds running on Unix-based platforms via Mono.
    /// </summary>
    internal static class PlatformInformation
    {
        public static bool IsWindows => Path.DirectorySeparatorChar == '\\';
        public static bool IsUnix => Path.DirectorySeparatorChar == '/';
    }
}
