using System.Collections.Concurrent;
using System.Diagnostics;
using BrassAndSigil.Server.Models;

namespace BrassAndSigil.Server.Services;

/// <summary>
/// Manages the Minecraft Java subprocess: spawns it with the right JVM args,
/// captures stdout/stderr into a ring buffer (so the web UI can show recent
/// logs), broadcasts new lines via an event, handles graceful shutdown.
/// </summary>
public sealed class ServerProcess : IDisposable
{
    private readonly ServerConfig _config;
    private Process? _process;
    private readonly ConcurrentQueue<LogLine> _logRing = new();
    private const int LogRingSize = 2000;

    // Process-tree containment: on Windows, the Job Object kills our subprocess
    // automatically when our own process dies -- regardless of how we died.
    // Created lazily on first Start() on Windows; destroyed in Dispose.
    private static WindowsJobObject? _jobObject;

    public event Action<LogLine>? OnLogLine;
    public event Action<int>? Exited;

    public bool IsRunning => _process is { HasExited: false };
    public DateTime? StartedAt { get; private set; }
    public int? Pid => _process?.Id;

    // Memory + CPU sampling. CpuMetrics is a moving average across calls -- the
    // first call after start returns null because we need two samples for a delta.
    // We track the Java *descendant* of our shell, not the shell itself, because
    // run.sh / run.bat is ~2 MB and useless for stats.
    private TimeSpan _lastCpuTime;
    private DateTime _lastCpuSampleAt;
    private int _lastTrackedPid;
    private Process? _trackedJava;
    private readonly object _statsLock = new();
    private readonly Queue<double> _cpuSamples = new();
    private const int CpuSampleWindow = 20; // ~60 s rolling window @ 3 s polling

    /// <summary>
    /// Returns the Java JVM descendant if found, otherwise falls back to the
    /// directly-spawned shell process. Cached and re-resolved when the cached
    /// pid exits (e.g., MC restart).
    /// </summary>
    private Process? TrackedProcess
    {
        get
        {
            if (_process is null || _process.HasExited) return null;
            if (_trackedJava is { HasExited: false }) return _trackedJava;
            // Try to find a 'java' descendant of our shell. If not found yet (still
            // booting), fall back to the shell -- first stats will read tiny, then
            // reset on next call once Java is up.
            var found = FindJavaDescendant(_process.Id);
            _trackedJava = found;
            return found ?? _process;
        }
    }

    public long? MemoryBytes
    {
        get
        {
            var p = TrackedProcess;
            if (p is null || p.HasExited) return null;
            try { p.Refresh(); return p.WorkingSet64; }
            catch { return null; }
        }
    }

    /// <summary>
    /// System-wide CPU percentage (0-100) plus rolling-window peak and average.
    /// Each call samples once and contributes to a 20-entry history (~60 s at 3 s
    /// polling). First call after start returns null (need two samples for a delta).
    /// </summary>
    public (double Current, double Max, double Avg)? CpuMetrics
    {
        get
        {
            var p = TrackedProcess;
            if (p is null || p.HasExited) return null;
            lock (_statsLock)
            {
                try
                {
                    p.Refresh();
                    var now = DateTime.UtcNow;
                    var cpuNow = p.TotalProcessorTime;
                    if (_lastTrackedPid != p.Id || _lastCpuSampleAt == default)
                    {
                        _lastTrackedPid  = p.Id;
                        _lastCpuTime    = cpuNow;
                        _lastCpuSampleAt = now;
                        _cpuSamples.Clear();
                        return null;
                    }
                    var elapsedReal = (now - _lastCpuSampleAt).TotalMilliseconds;
                    var elapsedCpu  = (cpuNow - _lastCpuTime).TotalMilliseconds;
                    _lastCpuTime = cpuNow;
                    _lastCpuSampleAt = now;
                    if (elapsedReal <= 0) return (0, 0, 0);

                    // System-wide: divide by core count so the value is bounded 0-100
                    // and intuitive ("42% of total CPU capacity"). The user found the
                    // top-style 0-N*100 range confusing for a fleet view.
                    var perCore    = elapsedCpu / elapsedReal * 100.0;
                    var systemWide = Math.Min(100.0, perCore / Environment.ProcessorCount);

                    _cpuSamples.Enqueue(systemWide);
                    while (_cpuSamples.Count > CpuSampleWindow) _cpuSamples.Dequeue();

                    return (systemWide, _cpuSamples.Max(), _cpuSamples.Average());
                }
                catch { return null; }
            }
        }
    }

    /// <summary>
    /// BFS the process tree starting from <paramref name="rootPid"/> looking for
    /// a process named like "java". Linux: read /proc/PID/task/PID/children.
    /// Windows: enumerate parents via Process objects (no extra deps).
    /// </summary>
    private static Process? FindJavaDescendant(int rootPid)
    {
        try
        {
            if (OperatingSystem.IsLinux())
            {
                return FindJavaDescendantLinux(rootPid);
            }
            if (OperatingSystem.IsWindows())
            {
                return FindJavaDescendantWindows(rootPid);
            }
        }
        catch { }
        return null;
    }

    private static Process? FindJavaDescendantLinux(int rootPid)
    {
        var visited = new HashSet<int>();
        var queue = new Queue<int>();
        queue.Enqueue(rootPid);
        while (queue.Count > 0)
        {
            var pid = queue.Dequeue();
            if (!visited.Add(pid)) continue;
            var childrenPath = $"/proc/{pid}/task/{pid}/children";
            if (!File.Exists(childrenPath)) continue;
            string raw;
            try { raw = File.ReadAllText(childrenPath); } catch { continue; }
            foreach (var token in raw.Split(' ', StringSplitOptions.RemoveEmptyEntries))
            {
                if (!int.TryParse(token, out var cpid)) continue;
                Process? p = null;
                try { p = Process.GetProcessById(cpid); } catch { continue; }
                if (p.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
                    return p;
                queue.Enqueue(cpid);
            }
        }
        return null;
    }

    private static Process? FindJavaDescendantWindows(int rootPid)
    {
        // Build a parent->children map by reading every running process's parent
        // PID once, then BFS. Slower than Linux's /proc but works without WMI.
        var allProcs = Process.GetProcesses();
        var byParent = new Dictionary<int, List<Process>>();
        foreach (var p in allProcs)
        {
            int parent;
            try { parent = GetParentPidWindows(p); } catch { continue; }
            if (parent == 0) continue;
            if (!byParent.TryGetValue(parent, out var list)) byParent[parent] = list = new List<Process>();
            list.Add(p);
        }
        var visited = new HashSet<int>();
        var queue = new Queue<int>();
        queue.Enqueue(rootPid);
        while (queue.Count > 0)
        {
            var pid = queue.Dequeue();
            if (!visited.Add(pid)) continue;
            if (!byParent.TryGetValue(pid, out var children)) continue;
            foreach (var c in children)
            {
                if (c.ProcessName.Equals("java", StringComparison.OrdinalIgnoreCase))
                    return c;
                queue.Enqueue(c.Id);
            }
        }
        return null;
    }

    [System.Runtime.InteropServices.DllImport("ntdll.dll")]
    private static extern int NtQueryInformationProcess(
        IntPtr processHandle, int processInformationClass,
        ref ProcessBasicInformation processInformation, int processInformationLength, out int returnLength);

    [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)]
    private struct ProcessBasicInformation
    {
        public IntPtr Reserved1;
        public IntPtr PebBaseAddress;
        public IntPtr Reserved2_0;
        public IntPtr Reserved2_1;
        public IntPtr UniqueProcessId;
        public IntPtr InheritedFromUniqueProcessId; // <-- parent PID
    }

    private static int GetParentPidWindows(Process p)
    {
        var info = new ProcessBasicInformation();
        int rc = NtQueryInformationProcess(p.Handle, 0, ref info,
            System.Runtime.InteropServices.Marshal.SizeOf<ProcessBasicInformation>(), out _);
        return rc != 0 ? 0 : info.InheritedFromUniqueProcessId.ToInt32();
    }

    public ServerProcess(ServerConfig config) => _config = config;

    public bool Start()
    {
        if (IsRunning) return false;

        // Reset cached Java descendant + CPU sampling baseline so the next call
        // re-resolves once the new run.sh -> java tree is up.
        _trackedJava = null;
        _lastTrackedPid = 0;
        _lastCpuSampleAt = default;

        // The mod loader's start script lives next to the server jar. NeoForge produces
        // run.bat / run.sh from its installer; CmlLib's NeoForgeInstaller doesn't run on
        // the server side, so we build the equivalent JVM command ourselves.
        var startScript = ResolveStartCommand(out var argList);
        var startInfo = new ProcessStartInfo
        {
            FileName = startScript,
            WorkingDirectory = Path.GetFullPath(_config.ServerDir),
            UseShellExecute = false,
            RedirectStandardInput = true,
            RedirectStandardOutput = true,
            RedirectStandardError = true,
            CreateNoWindow = true,
        };
        foreach (var a in argList) startInfo.ArgumentList.Add(a);

        // CRITICAL: NeoForge's run.bat / run.sh just calls plain `java`, which resolves to
        // whatever's on PATH. If we configured a specific Java (or auto-downloaded one),
        // prepend its bin dir + set JAVA_HOME so the script picks up the right JVM
        // instead of an older system Java.
        ApplyJavaEnvironment(startInfo);

        _process = new Process { StartInfo = startInfo, EnableRaisingEvents = true };
        _process.OutputDataReceived += (_, e) => OnLine(e.Data, isError: false);
        _process.ErrorDataReceived  += (_, e) => OnLine(e.Data, isError: true);
        _process.Exited += (_, _) =>
        {
            var code = _process?.ExitCode ?? -1;
            OnLine($"=== Server exited (code {code}) ===", isError: false);
            Exited?.Invoke(code);
        };

        _process.Start();

        // Bind the Java subprocess to a Job Object on Windows so it gets killed
        // automatically if we exit ungracefully (X-button, Task Manager, etc.).
        if (OperatingSystem.IsWindows())
        {
            try
            {
                _jobObject ??= new WindowsJobObject();
                _jobObject.AssignProcess(_process);
            }
            catch (Exception ex)
            {
                // Non-fatal -- we still try to clean up via Process.Kill in Dispose.
                OnLine($"[brass-sigil-server] Couldn't attach Job Object: {ex.Message}", isError: true);
            }
        }

        _process.BeginOutputReadLine();
        _process.BeginErrorReadLine();
        StartedAt = DateTime.UtcNow;
        return true;
    }

    private string ResolveStartCommand(out List<string> args)
    {
        args = new List<string>();
        var dir = Path.GetFullPath(_config.ServerDir);

        // Prefer NeoForge's generated run.sh / run.bat -- they include the right module-path JVM args.
        var runShell = OperatingSystem.IsWindows() ? "run.bat" : "run.sh";
        var runScript = Path.Combine(dir, runShell);
        if (File.Exists(runScript))
        {
            // Make sure the user_jvm_args.txt has the memory we want
            EnsureUserJvmArgs(dir);
            // Suppress the Swing server-GUI window -- we use the web panel instead.
            // NeoForge's run.bat / run.sh forwards extra args to Minecraft.
            args.Add("nogui");
            return runScript;
        }

        // Fallback: invoke java directly on the server jar. This won't work for NeoForge
        // (it requires the loader's run script), but it works for vanilla and Forge legacy.
        args.Add($"-Xms{_config.MemoryMB}M");
        args.Add($"-Xmx{_config.MemoryMB}M");
        args.Add("-jar");
        args.Add("server.jar");
        args.Add("nogui");
        return _config.JavaPath;
    }

    private void ApplyJavaEnvironment(ProcessStartInfo psi)
    {
        // Only meaningful when we have an absolute path to a specific java binary
        // (i.e., not just "java" on PATH).
        if (string.IsNullOrEmpty(_config.JavaPath)) return;
        if (!Path.IsPathRooted(_config.JavaPath)) return;
        if (!File.Exists(_config.JavaPath)) return;

        var javaBinDir = Path.GetDirectoryName(_config.JavaPath);
        if (string.IsNullOrEmpty(javaBinDir)) return;
        var javaHome = Path.GetDirectoryName(javaBinDir); // parent of bin/

        var sep = OperatingSystem.IsWindows() ? ";" : ":";
        var existingPath = Environment.GetEnvironmentVariable("PATH") ?? "";
        psi.Environment["PATH"] = $"{javaBinDir}{sep}{existingPath}";
        if (!string.IsNullOrEmpty(javaHome))
        {
            psi.Environment["JAVA_HOME"] = javaHome;
        }
    }

    private void EnsureUserJvmArgs(string dir)
    {
        // NeoForge's run.bat / run.sh reads this file for JVM-level args.
        // Generational ZGC: concurrent low-pause GC, recommended by Distant Horizons
        // and significantly better than G1 for heavily-modded MC. Requires Java 21+.
        var path = Path.Combine(dir, "user_jvm_args.txt");
        var content =
            $"-Xms{_config.MemoryMB}M\n" +
            $"-Xmx{_config.MemoryMB}M\n" +
            "-XX:+UseZGC\n" +
            "-XX:+ZGenerational\n";
        try { File.WriteAllText(path, content); } catch { /* best-effort */ }
    }

    private void OnLine(string? data, bool isError)
    {
        if (string.IsNullOrEmpty(data)) return;
        var line = new LogLine(DateTimeOffset.UtcNow, isError, data);
        _logRing.Enqueue(line);
        while (_logRing.Count > LogRingSize) _logRing.TryDequeue(out _);
        OnLogLine?.Invoke(line);
    }

    public IReadOnlyList<LogLine> RecentLogs() => _logRing.ToArray();

    public async Task SendInputAsync(string command, CancellationToken ct = default)
    {
        if (_process is null || _process.HasExited) return;
        await _process.StandardInput.WriteLineAsync(command.AsMemory(), ct);
        await _process.StandardInput.FlushAsync(ct);
    }

    public async Task<bool> StopAsync(TimeSpan? graceful = null, CancellationToken ct = default)
    {
        if (!IsRunning) return false;
        graceful ??= TimeSpan.FromSeconds(30);
        try
        {
            await SendInputAsync("stop", ct);
            using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
            cts.CancelAfter(graceful.Value);
            try { await _process!.WaitForExitAsync(cts.Token); return true; }
            catch (OperationCanceledException)
            {
                _process?.Kill(entireProcessTree: true);
                return false;
            }
        }
        catch
        {
            try { _process?.Kill(entireProcessTree: true); } catch { }
            return false;
        }
    }

    public void Dispose()
    {
        try { _process?.Kill(entireProcessTree: true); } catch { }
        _process?.Dispose();
    }

    public sealed record LogLine(DateTimeOffset At, bool IsError, string Text);
}
