// 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.Concurrent;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.E2ETesting;
using Microsoft.Extensions.Internal;
using Xunit.Abstractions;
using Xunit.Sdk;

namespace Microsoft.AspNetCore.E2ETesting
{
    public class SauceConnectServer : IDisposable
    {
        private static readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1);

        private Process _process;
        private string _sentinelPath;
        private Process _sentinelProcess;
        private static IMessageSink _diagnosticsMessageSink;

        // 2h
        private const int SauceConnectProcessTimeout = 7200;

        public SauceConnectServer(IMessageSink diagnosticsMessageSink)
        {
            if (Instance != null || _diagnosticsMessageSink != null)
            {
                throw new InvalidOperationException("Sauce connect singleton already created.");
            }

            // The assembly level attribute AssemblyFixture takes care of this being being instantiated before tests run
            // and disposed after tests are run, gracefully shutting down the server when possible by calling Dispose on
            // the singleton.
            Instance = this;
            _diagnosticsMessageSink = diagnosticsMessageSink;
        }

        private void Initialize(
            Process process,
            string sentinelPath,
            Process sentinelProcess)
        {
            _process = process;
            _sentinelPath = sentinelPath;
            _sentinelProcess = sentinelProcess;
        }

        internal static SauceConnectServer Instance { get; private set; }

        public static async Task StartAsync(ITestOutputHelper output)
        {
            try
            {
                await _semaphore.WaitAsync();
                if (Instance._process == null)
                {
                    // No process was started, meaning the instance wasn't initialized.
                    await InitializeInstance(output);
                }
            }
            finally
            {
                _semaphore.Release();
            }
        }

        private static async Task InitializeInstance(ITestOutputHelper output)
        {
            var psi = new ProcessStartInfo
            {
                FileName = "npm",
                Arguments = "run sauce --" +
                    $" --sauce-user {E2ETestOptions.Instance.Sauce.Username}" +
                    $" --sauce-key {E2ETestOptions.Instance.Sauce.AccessKey}" +
                    $" --sauce-tunnel {E2ETestOptions.Instance.Sauce.TunnelIdentifier}" +
                    $" --use-hostname {E2ETestOptions.Instance.Sauce.HostName}",
                RedirectStandardOutput = true,
                RedirectStandardError = true,
            };

            if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
            {
                psi.FileName = "cmd";
                psi.Arguments = $"/c npm {psi.Arguments}";
            }

            // It's important that we get the folder value before we start the process to prevent
            // untracked processes when the tracking folder is not correctly configure.
            var trackingFolder = GetProcessTrackingFolder();
            if (!Directory.Exists(trackingFolder))
            {
                throw new InvalidOperationException($"Invalid tracking folder. Set the 'SauceConnectProcessTrackingFolder' MSBuild property to a valid folder.");
            }

            Process process = null;
            Process sentinel = null;
            string pidFilePath = null;
            try
            {
                process = Process.Start(psi);
                pidFilePath = await WriteTrackingFileAsync(output, trackingFolder, process);
                sentinel = StartSentinelProcess(process, pidFilePath, SauceConnectProcessTimeout);
            }
            catch
            {
                ProcessCleanup(process, pidFilePath);
                ProcessCleanup(sentinel, pidFilePath: null);
                throw;
            }

            // Log output for sauce connect process.
            // This is for the case where the server fails to launch.
            var logOutput = new BlockingCollection<string>();

            process.OutputDataReceived += LogOutput;
            process.ErrorDataReceived += LogOutput;

            process.BeginOutputReadLine();
            process.BeginErrorReadLine();

            // The Sauce connect server has to be up for the entirety of the tests and is only shutdown when the application (i.e. the test) exits.
            AppDomain.CurrentDomain.ProcessExit += (sender, args) => ProcessCleanup(process, pidFilePath);

            // Log
            void LogOutput(object sender, DataReceivedEventArgs e)
            {
                logOutput.TryAdd(e.Data);

                // We avoid logging on the output here because it is unreliable. We can only log in the diagnostics sink.
                lock (_diagnosticsMessageSink)
                {
                    _diagnosticsMessageSink.OnMessage(new DiagnosticMessage(e.Data));
                }
            }

            var uri = new UriBuilder("http", E2ETestOptions.Instance.Sauce.HostName, 4445).Uri;
            var httpClient = new HttpClient
            {
                Timeout = TimeSpan.FromSeconds(1),
            };

            var retries = 0;
            do
            {
                await Task.Delay(1000);
                try
                {
                    var response = await httpClient.GetAsync(uri);
                    if (response.StatusCode == HttpStatusCode.OK)
                    {
                        output = null;
                        Instance.Initialize(process, pidFilePath, sentinel);
                        return;
                    }
                }
                catch (OperationCanceledException)
                {
                }
                catch (HttpRequestException)
                {
                }

                retries++;
            } while (retries < 30);

            // Make output null so that we stop logging to it.
            output = null;
            logOutput.CompleteAdding();
            var exitCodeString = process.HasExited ? process.ExitCode.ToString(CultureInfo.InvariantCulture) : "Process has not yet exited.";
            var message = $@"Failed to launch the server.
ExitCode: {exitCodeString}
Captured output lines:
{string.Join(Environment.NewLine, logOutput.GetConsumingEnumerable())}.";

            // If we got here, we couldn't launch Sauce connect or get it to respond. So shut it down.
            ProcessCleanup(process, pidFilePath);
            throw new InvalidOperationException(message);
        }

        private static Process StartSentinelProcess(Process process, string sentinelFile, int timeout)
        {
            // This sentinel process will start and will kill any rouge sauce connect server that wasn't torn down via normal means.
            var psi = new ProcessStartInfo
            {
                FileName = "powershell",
                Arguments = $"-NoProfile -NonInteractive -Command \"Start-Sleep {timeout}; " +
                $"if(Test-Path {sentinelFile}){{ " +
                $"Write-Output 'Stopping process {process.Id}'; Stop-Process {process.Id}; }}" +
                $"else{{ Write-Output 'Sentinel file {sentinelFile} not found.'}}",
            };

            return Process.Start(psi);
        }

        private static void ProcessCleanup(Process process, string pidFilePath)
        {
            try
            {
                if (process?.HasExited == false)
                {
                    try
                    {
                        process?.KillTree(TimeSpan.FromSeconds(10));
                        process?.Dispose();
                    }
                    catch
                    {
                        // Ignore errors here since we can't do anything
                    }
                }
                if (pidFilePath != null && File.Exists(pidFilePath))
                {
                    File.Delete(pidFilePath);
                }
            }
            catch
            {
                // Ignore errors here since we can't do anything
            }
        }

        private static async Task<string> WriteTrackingFileAsync(ITestOutputHelper output, string trackingFolder, Process process)
        {
            var pidFile = Path.Combine(trackingFolder, $"{process.Id}.{Guid.NewGuid()}.pid");
            for (var i = 0; i < 3; i++)
            {
                try
                {
                    await File.WriteAllTextAsync(pidFile, process.Id.ToString(CultureInfo.InvariantCulture));
                    return pidFile;
                }
                catch
                {
                    output.WriteLine($"Can't write file to process tracking folder: {trackingFolder}");
                }
            }

            throw new InvalidOperationException($"Failed to write file for process {process.Id}");
        }

        private static string GetProcessTrackingFolder() =>
            typeof(SauceConnectServer).Assembly
                .GetCustomAttributes<AssemblyMetadataAttribute>()
                .Single(a => a.Key == "Microsoft.AspNetCore.Testing.SauceConnect.ProcessTracking").Value;

        public void Dispose()
        {
            ProcessCleanup(_process, _sentinelPath);
            ProcessCleanup(_sentinelProcess, pidFilePath: null);
        }
    }
}
