// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Threading.Tasks;
using Xunit;
using Xunit.Abstractions;

namespace Microsoft.DotNet.SourceBuild.SmokeTests;

internal class DotNetHelper
{
    private static readonly object s_lockObj = new();

    public static string DotNetPath { get; } = Path.Combine(Config.DotNetDirectory, "dotnet");
    public static string LogsDirectory { get; } = Path.Combine(Directory.GetCurrentDirectory(), "logs");
    public static string PackagesDirectory { get; } = Path.Combine(Directory.GetCurrentDirectory(), "packages");
    public static string ProjectsDirectory { get; } = Path.Combine(Directory.GetCurrentDirectory(), $"projects-{DateTime.Now:yyyyMMddHHmmssffff}");

    private ITestOutputHelper OutputHelper { get; }
    public bool IsMonoRuntime { get; }

    public DotNetHelper(ITestOutputHelper outputHelper)
    {
        OutputHelper = outputHelper;

        lock (s_lockObj)
        {
            if (!Directory.Exists(Config.DotNetDirectory))
            {
                if (!File.Exists(Config.SdkTarballPath))
                {
                    throw new InvalidOperationException($"Tarball path '{Config.SdkTarballPath}' specified in {Config.SdkTarballPath} does not exist.");
                }

                Directory.CreateDirectory(Config.DotNetDirectory);
                Utilities.ExtractTarball(Config.SdkTarballPath, Config.DotNetDirectory, outputHelper);
            }
            IsMonoRuntime = DetermineIsMonoRuntime(Config.DotNetDirectory);

            if (!Directory.Exists(ProjectsDirectory))
            {
                Directory.CreateDirectory(ProjectsDirectory);
                InitNugetConfig();
            }

            if (!Directory.Exists(PackagesDirectory))
            {
                Directory.CreateDirectory(PackagesDirectory);
            }

            if (!Directory.Exists(LogsDirectory))
            {
                Directory.CreateDirectory(LogsDirectory);
            }
        }
    }

    private static void InitNugetConfig()
    {
        bool useLocalPackages = !string.IsNullOrEmpty(Config.PrereqsPath);
        string nugetConfigPrefix = useLocalPackages ? "local" : "online";
        string nugetConfigPath = Path.Combine(ProjectsDirectory, "NuGet.Config");
        File.Copy(
            Path.Combine(BaselineHelper.GetAssetsDirectory(), $"{nugetConfigPrefix}.NuGet.Config"),
            nugetConfigPath);

        if (useLocalPackages)
        {
            // When using local packages this feed is always required.  It contains packages that are
            // not produced by source-build but are required by the various project templates.
            if (!Directory.Exists(Config.PrereqsPath))
            {
                throw new InvalidOperationException(
                    $"Prereqs path '{Config.PrereqsPath}' specified in {Config.PrereqsPathEnv} does not exist.");
            }

            string nugetConfig = File.ReadAllText(nugetConfigPath);
            nugetConfig = nugetConfig.Replace("SMOKE_TEST_PACKAGE_FEED", Config.PrereqsPath);

            // This package feed is optional.  You can use an additional feed of source-built packages to run the
            // smoke-tests as offline as possible.
            if (Config.CustomPackagesPath != null)
            {
                if (!Directory.Exists(Config.CustomPackagesPath))
                {
                    throw new ArgumentException($"Specified --with-packages {Config.CustomPackagesPath} does not exist.");
                }
                nugetConfig = nugetConfig.Replace("CUSTOM_PACKAGE_FEED", Config.CustomPackagesPath);
            }
            else
            {
                nugetConfig = string.Join(Environment.NewLine, nugetConfig.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).Where(s => !s.Contains("CUSTOM_PACKAGE_FEED")).ToArray());
            }
            File.WriteAllText(nugetConfigPath, nugetConfig);
        }
    }

    public void ExecuteCmd(string args, string? workingDirectory = null, Action<Process>? processConfigCallback = null,
        int? expectedExitCode = 0, int millisecondTimeout = -1)
    {
        (Process Process, string StdOut, string StdErr) executeResult = ExecuteHelper.ExecuteProcess(
            DotNetPath,
            args,
            OutputHelper,
            configureCallback: (process) => configureProcess(process, workingDirectory),
            millisecondTimeout: millisecondTimeout);
        
        if (expectedExitCode != null) {
            ExecuteHelper.ValidateExitCode(executeResult, (int) expectedExitCode);
        }

        void configureProcess(Process process, string? workingDirectory)
        {
            ConfigureProcess(process, workingDirectory);

            processConfigCallback?.Invoke(process);
        }
    }

    public static void ConfigureProcess(Process process, string? workingDirectory, bool setPath = false)
    {
        if (workingDirectory != null)
        {
            process.StartInfo.WorkingDirectory = workingDirectory;
        }

        process.StartInfo.EnvironmentVariables["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
        process.StartInfo.EnvironmentVariables["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";
        process.StartInfo.EnvironmentVariables["DOTNET_ROOT"] = Config.DotNetDirectory;
        process.StartInfo.EnvironmentVariables["NUGET_PACKAGES"] = PackagesDirectory;

        if (setPath)
        {
            process.StartInfo.EnvironmentVariables["PATH"] = $"{Config.DotNetDirectory}:{Environment.GetEnvironmentVariable("PATH")}";
        }
    }

    public void ExecuteBuild(string projectName) =>
        ExecuteCmd($"build {GetBinLogOption(projectName, "build")}", GetProjectDirectory(projectName));

    /// <summary>
    /// Create a new .NET project and return the path to the created project folder.
    /// </summary>
    public string ExecuteNew(string projectType, string name, string? language = null, string? customArgs = null)
    {
        string projectDirectory = GetProjectDirectory(name);
        string options = $"--name {name} --output {projectDirectory}";
        if (language != null)
        {
            options += $" --language \"{language}\"";
        }
        if (string.IsNullOrEmpty(customArgs))
        {
            options += $" {customArgs}";
        }

        ExecuteCmd($"new {projectType} {options}");

        return projectDirectory;
    }

    public void ExecutePublish(string projectName, DotNetTemplate template, bool? selfContained = null, string? rid = null, bool trimmed = false, bool readyToRun = false)
    {
        string options = string.Empty;
        string binlogDifferentiator = string.Empty;

        if (selfContained.HasValue)
        {
            options += $"--self-contained {selfContained.Value.ToString().ToLowerInvariant()}";
            if (selfContained.Value)
            {
                binlogDifferentiator += "self-contained";
                if (!string.IsNullOrEmpty(rid))
                {
                    options += $" -r {rid}";
                    binlogDifferentiator += $"-{rid}";
                }
                if (trimmed)
                {
                    options += " /p:PublishTrimmed=true";
                    binlogDifferentiator += "-trimmed";
                }
                if (readyToRun)
                {
                    options += " /p:PublishReadyToRun=true";
                    binlogDifferentiator += "-R2R";
                }
            }
        }

        string projDir = GetProjectDirectory(projectName);
        string publishDir = Path.Combine(projDir, "bin", "publish");

        ExecuteCmd(
            $"publish {options} {GetBinLogOption(projectName, "publish", binlogDifferentiator)} -o {publishDir}",
            projDir);

        if (template == DotNetTemplate.Console)
        {
            ExecuteCmd($"{projectName}.dll", publishDir, expectedExitCode: 0);
        }
        else if (template == DotNetTemplate.ClassLib || template == DotNetTemplate.BlazorWasm)
        {
            // Can't run the published output of classlib (no entrypoint) or WASM (needs a server)
        }
        // Assume it is a web-based template
        else
        {
            ExecuteWebDll(projectName, publishDir, template);
        }
    }

    public void ExecuteRun(string projectName) =>
        ExecuteCmd($"run {GetBinLogOption(projectName, "run")}", GetProjectDirectory(projectName));

    public void ExecuteRunWeb(string projectName, DotNetTemplate template)
    {
        // 'dotnet run' exit code differs between CoreCLR and Mono (https://github.com/dotnet/sdk/issues/30095).
        int expectedExitCode = IsMonoRuntime ? 143 : 0;

        ExecuteWeb(
            projectName,
            $"run --no-launch-profile {GetBinLogOption(projectName, "run")}",
            GetProjectDirectory(projectName),
            template,
            expectedExitCode);
    }

    public void ExecuteWebDll(string projectName, string workingDirectory, DotNetTemplate template) =>
        ExecuteWeb(projectName, $"{projectName}.dll", workingDirectory, template, expectedExitCode: 0);

    public void ExecuteTest(string projectName) =>
        ExecuteCmd($"test {GetBinLogOption(projectName, "test")}", GetProjectDirectory(projectName));

    private void ExecuteWeb(string projectName, string args, string workingDirectory, DotNetTemplate template, int expectedExitCode)
    {
        WebAppValidator validator = new(OutputHelper, template);
        ExecuteCmd(
            args,
            workingDirectory,
            processConfigCallback: validator.Validate,
            expectedExitCode: expectedExitCode,
            millisecondTimeout: 30000);
        Assert.True(validator.IsValidated);
        if (validator.ValidationException is not null)
        {
            throw validator.ValidationException;
        }
    }    

    private static string GetBinLogOption(string projectName, string command, string? differentiator = null)
    {
        string fileName = $"{projectName}-{command}";
        if (!string.IsNullOrEmpty(differentiator))
        {
            fileName += $"-{differentiator}";
        }

        return $"/bl:{Path.Combine(LogsDirectory, $"{fileName}.binlog")}";
    }

    private static bool DetermineIsMonoRuntime(string dotnetRoot)
    {
        string sharedFrameworkRoot = Path.Combine(dotnetRoot, "shared", "Microsoft.NETCore.App");
        if (!Directory.Exists(sharedFrameworkRoot))
        {
            return false;
        }

        string? version = Directory.GetDirectories(sharedFrameworkRoot).FirstOrDefault();
        if (version is null)
        {
            return false;
        }

        string sharedFramework = Path.Combine(sharedFrameworkRoot, version);

        // Check the presence of one of the mono header files.
        return File.Exists(Path.Combine(sharedFramework, "mono-gc.h"));
    }

    private static string GetProjectDirectory(string projectName) => Path.Combine(ProjectsDirectory, projectName);

    private class WebAppValidator
    {
        private readonly ITestOutputHelper _outputHelper;
        private readonly DotNetTemplate _template;

        public WebAppValidator(ITestOutputHelper outputHelper, DotNetTemplate template)
        {
            _outputHelper = outputHelper;
            _template = template;
        }

        public bool IsValidated { get; set; }
        public Exception? ValidationException { get; set; }

        private static int GetAvailablePort()
        {
            TcpListener listener = new(IPAddress.Loopback, 0);
            listener.Start();
            int port = ((IPEndPoint)listener.LocalEndpoint).Port;
            listener.Stop();
            return port;
        }

        public void Validate(Process process)
        {
            int port = GetAvailablePort();
            process.StartInfo.EnvironmentVariables.Add("ASPNETCORE_HTTP_PORTS", port.ToString());
            process.OutputDataReceived += new DataReceivedEventHandler((sender, e) =>
            {
                try
                {
                    if (e.Data?.Contains("Application started. Press Ctrl+C to shut down.") ?? false)
                    {
                        _outputHelper.WriteLine("Detected app has started. Sending web request to validate...");

                        using HttpClient httpClient = new();
                        string url = $"http://localhost:{port}";
                        if (_template == DotNetTemplate.WebApi)
                        {
                            url += "/WeatherForecast";
                        }

                        using HttpResponseMessage resultMsg = httpClient.GetAsync(new Uri(url)).Result;
                        _outputHelper.WriteLine($"Status code returned: {resultMsg.StatusCode}");
                        resultMsg.EnsureSuccessStatusCode();
                        IsValidated = true;

                        ExecuteHelper.ExecuteProcessValidateExitCode("kill", $"-s TERM {process.Id}", _outputHelper);
                    }
                }
                catch (Exception ex)
                {
                    ValidationException = ex;
                }
            });
        }
    }
}
