// 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 System.Runtime.InteropServices;
using Xunit.Abstractions;

namespace Aspire.Workload.Tests;

public class ToolCommand : IDisposable
{
    private readonly string _label;
    private TimeSpan? _timeout;
    private readonly string _msgPrefix;
    protected ITestOutputHelper _testOutput;

    protected string _command;

    public Process? CurrentProcess { get; private set; }

    public Dictionary<string, string> Environment { get; } = new Dictionary<string, string>();

    public event DataReceivedEventHandler? ErrorDataReceived;

    public event DataReceivedEventHandler? OutputDataReceived;

    public string? WorkingDirectory { get; set; }

    public ToolCommand(string command, ITestOutputHelper testOutput, string label="")
    {
        _command = command;
        _testOutput = testOutput;
        _label = label;
        _msgPrefix = string.IsNullOrEmpty(_label) ? string.Empty : $"[{_label}] ";
    }

    public ToolCommand WithWorkingDirectory(string dir)
    {
        WorkingDirectory = dir;
        return this;
    }

    public ToolCommand WithEnvironmentVariable(string key, string value)
    {
        Environment[key] = value;
        return this;
    }

    public ToolCommand WithEnvironmentVariables(IDictionary<string, string>? extraEnvVars)
    {
        if (extraEnvVars != null)
        {
            foreach ((string key, string value) in extraEnvVars)
            {
                Environment[key] = value;
            }
        }

        return this;
    }

    public ToolCommand WithOutputDataReceived(Action<string?> handler)
    {
        OutputDataReceived += (_, args) => handler(args.Data);
        return this;
    }

    public ToolCommand WithErrorDataReceived(Action<string?> handler)
    {
        ErrorDataReceived += (_, args) => handler(args.Data);
        return this;
    }

    public ToolCommand WithTimeout(TimeSpan timeSpan)
    {
        _timeout = timeSpan;
        return this;
    }

    public virtual async Task<CommandResult> ExecuteAsync(params string[] args)
    {
        var resolvedCommand = _command;
        string fullArgs = GetFullArgs(args);
        CancellationTokenSource cts = new();
        if (_timeout is not null)
        {
            cts.CancelAfter((int)_timeout.Value.TotalMilliseconds);
        }
        try
        {
            return await ExecuteAsyncInternal(resolvedCommand, fullArgs, cts.Token);
        }
        catch (TaskCanceledException tce) when (cts.IsCancellationRequested)
        {
            throw new TaskCanceledException($"Command execution timed out after {_timeout!.Value.TotalSeconds} secs: '{resolvedCommand} {fullArgs}'", tce);
        }
    }

    public virtual void Dispose()
    {
        CurrentProcess?.CloseAndKillProcessIfRunning();
    }

    protected virtual string GetFullArgs(params string[] args) => string.Join(" ", args);

    private async Task<CommandResult> ExecuteAsyncInternal(string executable, string args, CancellationToken token)
    {
        Stopwatch runTimeStopwatch = new();
        _testOutput.WriteLine($"{_msgPrefix}Executing - {executable} {args} {WorkingDirectoryInfo()}");
        object outputLock = new();
        var outputLines = new List<string>();
        CurrentProcess = CreateProcess(executable, args);
        CurrentProcess.ErrorDataReceived += (s, e) =>
        {
            if (e.Data == null)
            {
                return;
            }

            lock (outputLock)
            {
                outputLines.Add(e.Data);
            }
            _testOutput.WriteLine($"{_msgPrefix}{e.Data}");
            ErrorDataReceived?.Invoke(s, e);
        };

        CurrentProcess.OutputDataReceived += (s, e) =>
        {
            if (e.Data == null)
            {
                return;
            }

            lock (outputLock)
            {
                outputLines.Add(e.Data);
            }
            _testOutput.WriteLine($"{_msgPrefix}{e.Data}");
            OutputDataReceived?.Invoke(s, e);
        };

        try
        {
            runTimeStopwatch.Start();

            TaskCompletionSource exitedTcs = new();
            CurrentProcess.EnableRaisingEvents = true;
            CurrentProcess.Exited += (s, a) =>
            {
                exitedTcs.SetResult();
                runTimeStopwatch.Stop();
            };

            // Start
            CurrentProcess.Start();
            CurrentProcess.BeginOutputReadLine();
            CurrentProcess.BeginErrorReadLine();
            await exitedTcs.Task.WaitAsync(token).ConfigureAwait(false);

            // Exited, wait for output to complete now
            _testOutput.WriteLine($"{_msgPrefix}Got the Exited event, and waiting on WaitForExitAsync now");
            var waitForExitTask = CurrentProcess.WaitForExitAsync(token);
            var completedTask = await Task.WhenAny(waitForExitTask, Task.Delay(TimeSpan.FromSeconds(5), token)).ConfigureAwait(false);
            if (completedTask != waitForExitTask)
            {
                _testOutput.WriteLine($"{_msgPrefix}Timed out waiting for it. Ignoring.");
            }

            _testOutput.WriteLine($"{_msgPrefix}Process ran for {runTimeStopwatch.Elapsed.TotalSeconds} secs");

            return new CommandResult(
                CurrentProcess.StartInfo,
                CurrentProcess.ExitCode,
                GetFullOutput());
        }
        catch (Exception ex)
        {
            _testOutput.WriteLine($"Exception: {ex}");
            _testOutput.WriteLine($"output: {GetFullOutput()}");
            throw;
        }
        finally
        {
            if (!CurrentProcess.TryGetHasExited())
            {
                _testOutput.WriteLine($"{_msgPrefix}Process has been running for {runTimeStopwatch.Elapsed.TotalSeconds} secs");
                if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
                {
                    CurrentProcess.CloseMainWindow();
                }

                _testOutput.WriteLine($"Killing");
                CurrentProcess.Kill(entireProcessTree: true);
            }
            CurrentProcess.Dispose();
        }

        string GetFullOutput()
        {
            lock (outputLock)
            {
                return string.Join(System.Environment.NewLine, outputLines);
            }
        }
    }

    private Process CreateProcess(string executable, string args)
    {
        var psi = new ProcessStartInfo
        {
            FileName = executable,
            Arguments = args,
            RedirectStandardError = true,
            RedirectStandardOutput = true,
            RedirectStandardInput = true,
            UseShellExecute = false
        };

        psi.Environment["DOTNET_MULTILEVEL_LOOKUP"] = "0";
        psi.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1";

        // runtime repo sets this, which interferes with the tests
        psi.EnvironmentVariables.Remove("MSBuildSDKsPath");

        AddEnvironmentVariablesTo(psi);
        AddWorkingDirectoryTo(psi);
        return new Process
        {
            StartInfo = psi,
            EnableRaisingEvents = true
        };
    }

    private string WorkingDirectoryInfo()
    {
        if (WorkingDirectory == null)
        {
            return "";
        }

        return $" in pwd {WorkingDirectory}";
    }

    private void AddEnvironmentVariablesTo(ProcessStartInfo psi)
    {
        foreach (var item in Environment)
        {
            _testOutput.WriteLine($"{_msgPrefix}\t[{item.Key}] = {item.Value}");
            psi.Environment[item.Key] = item.Value;
        }
    }

    private void AddWorkingDirectoryTo(ProcessStartInfo psi)
    {
        if (!string.IsNullOrWhiteSpace(WorkingDirectory))
        {
            if (!Directory.Exists(WorkingDirectory))
            {
                throw new DirectoryNotFoundException($"Working directory '{WorkingDirectory}' does not exist.");
            }
            psi.WorkingDirectory = WorkingDirectory;
        }
    }
}
