using System.Diagnostics;
using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests.Cluster;

/// <summary>
/// Subprocess runner for the ScadaBridge CLI, used by state-changing Central UI
/// Playwright E2E tests to provision fixtures (sites, templates, instances, …)
/// against the running dev cluster.
///
/// <para>
/// Shells out to <c>dotnet scadabridge.dll --url &lt;Url&gt; --username multi-role
/// --password password --format json &lt;args&gt;</c>. The CLI uses Basic Auth per
/// request; <c>multi-role</c>/<c>password</c> carries Admin + Design + Deployment
/// roles. Exit code 0 = success and JSON is printed to stdout; a non-zero exit
/// surfaces as an <see cref="InvalidOperationException"/> carrying the captured
/// stderr.
/// </para>
///
/// <para>
/// The built CLI assembly is named <c>scadabridge.dll</c> (the test project takes
/// a <c>ReferenceOutputAssembly="false"</c> ProjectReference on the CLI so it is
/// always built alongside the tests). <see cref="ResolveCliDll"/> honours the
/// <c>SCADABRIDGE_CLI_DLL</c> env override, otherwise walks up from the test
/// assembly to the repo root and probes the Debug/Release output paths.
/// </para>
/// </summary>
public static partial class CliRunner
{
    /// <summary>Hard timeout for a single CLI invocation.</summary>
    private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(60);

    private const string ManagementUrlEnvVar = "SCADABRIDGE_MANAGEMENT_URL";
    private const string CliDllEnvVar = "SCADABRIDGE_CLI_DLL";
    private const string DefaultUrl = "http://localhost:9000";

    private const string CliProjectRelativeDir = "src/ZB.MOM.WW.ScadaBridge.CLI";
    private const string CliDllName = "scadabridge.dll";

    private const string CliUsername = "multi-role";
    private const string CliPassword = "password";

    private static readonly object DllLock = new();
    private static volatile string? _cliDll;

    /// <summary>
    /// Management URL the CLI connects to (via the Traefik load balancer).
    /// Resolved from <c>SCADABRIDGE_MANAGEMENT_URL</c> when set, otherwise the
    /// local docker dev default (<c>http://localhost:9000</c>).
    /// </summary>
    public static string Url
    {
        get
        {
            var fromEnv = Environment.GetEnvironmentVariable(ManagementUrlEnvVar);
            return string.IsNullOrWhiteSpace(fromEnv) ? DefaultUrl : fromEnv;
        }
    }

    /// <summary>
    /// Invokes the CLI with the given <paramref name="args"/> and returns its raw
    /// stdout. The connection flags (<c>--url</c>, <c>--username</c>,
    /// <c>--password</c>, <c>--format json</c>) are prepended automatically.
    /// </summary>
    /// <exception cref="TimeoutException">
    /// The invocation exceeded the 60-second timeout; the process tree is killed.
    /// </exception>
    /// <exception cref="InvalidOperationException">
    /// The CLI exited non-zero; the message carries the captured stderr.
    /// </exception>
    public static async Task<string> RunAsync(params string[] args)
    {
        var startInfo = new ProcessStartInfo
        {
            FileName = "dotnet",
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            UseShellExecute = false,
            CreateNoWindow = true,
        };

        startInfo.ArgumentList.Add(ResolveCliDll());
        startInfo.ArgumentList.Add("--url");
        startInfo.ArgumentList.Add(Url);
        startInfo.ArgumentList.Add("--username");
        startInfo.ArgumentList.Add(CliUsername);
        startInfo.ArgumentList.Add("--password");
        startInfo.ArgumentList.Add(CliPassword);
        startInfo.ArgumentList.Add("--format");
        startInfo.ArgumentList.Add("json");
        foreach (var arg in args)
        {
            startInfo.ArgumentList.Add(arg);
        }

        using var process = new Process { StartInfo = startInfo };
        if (!process.Start())
        {
            throw new InvalidOperationException(
                $"Failed to start CLI process for [{string.Join(' ', args)}].");
        }

        var stdoutTask = process.StandardOutput.ReadToEndAsync();
        var stderrTask = process.StandardError.ReadToEndAsync();

        using var cts = new CancellationTokenSource(Timeout);
        try
        {
            await process.WaitForExitAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            TryKill(process);
            // Drain both pipes so the abandoned read tasks complete cleanly before disposal.
            try { await Task.WhenAll(stdoutTask, stderrTask).WaitAsync(TimeSpan.FromSeconds(5)); }
            catch { /* best-effort — we are already throwing TimeoutException */ }
            throw new TimeoutException(
                $"CLI [{string.Join(' ', args)}] did not exit within {Timeout.TotalSeconds:F0}s and was killed.");
        }

        var stdout = await stdoutTask;
        var stderr = await stderrTask;

        if (process.ExitCode != 0)
        {
            throw new InvalidOperationException(
                $"CLI [{string.Join(' ', args)}] exited {process.ExitCode}. stderr: {stderr}");
        }

        return stdout;
    }

    /// <summary>
    /// Invokes the CLI with the given <paramref name="args"/> and parses its stdout
    /// as a <see cref="JsonDocument"/>. The caller owns the returned document and
    /// must dispose it.
    /// </summary>
    public static async Task<JsonDocument> RunJsonAsync(params string[] args) =>
        JsonDocument.Parse(await RunAsync(args));

    /// <summary>
    /// Locates the built <c>scadabridge.dll</c>. Honours the
    /// <c>SCADABRIDGE_CLI_DLL</c> env override; otherwise walks up from the test
    /// assembly's base directory to the repo root (the directory containing
    /// <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>) and probes the <c>Debug</c>/<c>Release</c>
    /// build outputs — preferring the configuration the tests were built under.
    /// The resolved path is cached.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    /// The dll could not be found; the message lists every probed path.
    /// </exception>
    private static string ResolveCliDll()
    {
        if (_cliDll is { } cached)
        {
            return cached;
        }

        lock (DllLock)
        {
            if (_cliDll is { } cachedInLock)
            {
                return cachedInLock;
            }

            var fromEnv = Environment.GetEnvironmentVariable(CliDllEnvVar);
            if (!string.IsNullOrWhiteSpace(fromEnv))
            {
                if (!File.Exists(fromEnv))
                {
                    throw new InvalidOperationException(
                        $"{CliDllEnvVar} is set to '{fromEnv}' but no file exists there.");
                }

                _cliDll = fromEnv;
                return _cliDll;
            }

            var baseDir = AppContext.BaseDirectory;
            var repoRoot = FindRepoRoot(baseDir)
                ?? throw new InvalidOperationException(
                    $"Could not locate the repo root (a directory containing '{CliProjectRelativeDir}') " +
                    $"by walking up from '{baseDir}'. Set {CliDllEnvVar} to the absolute path of {CliDllName}.");

            var cliProjectDir = Path.Combine(repoRoot, CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar));
            var binDir = Path.Combine(cliProjectDir, "bin");

            // Prefer the configuration the tests were built under (derived from
            // the test assembly path: …/bin/<config>/net10.0/), then fall back.
            var probeConfigs = OrderConfigs(baseDir);

            var probed = new List<string>();
            foreach (var config in probeConfigs)
            {
                var candidate = Path.Combine(binDir, config, "net10.0", CliDllName);
                probed.Add(candidate);
                if (File.Exists(candidate))
                {
                    _cliDll = candidate;
                    return _cliDll;
                }
            }

            var probedList = string.Join(Environment.NewLine + "  ", probed);
            throw new InvalidOperationException(
                $"Could not find {CliDllName}. Build the CLI (it is referenced by this test project) " +
                $"or set {CliDllEnvVar}. Probed:" + Environment.NewLine + "  " + probedList);
        }
    }

    /// <summary>
    /// Walks up from <paramref name="startDir"/> looking for the repo root, i.e.
    /// the first ancestor directory that contains <c>src/ZB.MOM.WW.ScadaBridge.CLI</c>.
    /// Returns <see langword="null"/> if no such ancestor exists.
    /// </summary>
    private static string? FindRepoRoot(string startDir)
    {
        var marker = CliProjectRelativeDir.Replace('/', Path.DirectorySeparatorChar);
        for (var dir = new DirectoryInfo(startDir); dir is not null; dir = dir.Parent)
        {
            if (Directory.Exists(Path.Combine(dir.FullName, marker)))
            {
                return dir.FullName;
            }
        }

        return null;
    }

    /// <summary>
    /// Returns the build configurations to probe, ordered so the configuration the
    /// tests were built under (inferred from the test assembly path segment, e.g.
    /// <c>…/bin/Release/net10.0/</c>) is tried first.
    /// </summary>
    private static string[] OrderConfigs(string baseDir)
    {
        var normalized = baseDir.Replace('\\', '/');
        if (normalized.Contains("/bin/Release/", StringComparison.OrdinalIgnoreCase))
        {
            return ["Release", "Debug"];
        }

        // Default to Debug-first (covers /bin/Debug/ and any unrecognised layout).
        return ["Debug", "Release"];
    }

    /// <summary>
    /// Best-effort kill of the CLI process and its descendants. Swallows any error
    /// raised by a race with normal exit.
    /// </summary>
    private static void TryKill(Process process)
    {
        try
        {
            if (!process.HasExited)
            {
                process.Kill(entireProcessTree: true);
            }
        }
        catch
        {
            // Best-effort — the process may have exited between the check and the
            // kill, which is the outcome we wanted anyway.
        }
    }
}
