using System.Collections.Concurrent;
using System.Diagnostics;
using System.Text.Json;
using BrassAndSigil.Server.Models;
using BrassAndSigil.Server.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Embedded;
using Microsoft.Extensions.Hosting;
using Spectre.Console;
using Spectre.Console.Cli;

namespace BrassAndSigil.Server.Commands;

public sealed class RunCommand : AsyncCommand<BaseCommandSettings>
{
    public override async Task<int> ExecuteAsync(CommandContext context, BaseCommandSettings settings)
    {
        var configPath = settings.ConfigPath;
        var config = ServerConfig.Load(configPath);

        AnsiConsole.MarkupLine("[bold yellow]Brass & Sigil Server[/]");
        AnsiConsole.MarkupLine($"[grey]config: {configPath}[/]");
        AnsiConsole.MarkupLine("");

        // ────────── First-run admin setup ──────────
        // Fail-secure: if WebPassword is null (key missing or never set), force a
        // password to be configured before doing anything else. Empty string ("")
        // is the explicit "no auth" opt-out and is rejected on non-localhost binds.
        // Done here, BEFORE the heavy install + MC start so the prompt isn't
        // drowned out by streaming log output.
        if (config.WebPassword is null)
        {
            if (Console.IsInputRedirected)
            {
                // Non-interactive (systemd, piped SSH): don't auto-generate. The
                // gate below leaves /api/auth/setup open so the operator can set
                // a password on first visit to the panel; everything else stays
                // locked until they do.
                AnsiConsole.MarkupLine("");
                AnsiConsole.MarkupLine("[bold yellow]═══ FIRST-RUN: admin password not set ═══[/]");
                AnsiConsole.MarkupLine("[grey]Open the web panel in your browser and you'll be walked through[/]");
                AnsiConsole.MarkupLine("[grey]setting one. Everything except /api/auth/setup is 401-gated until then.[/]");
                AnsiConsole.MarkupLine("");
            }
            else
            {
                AnsiConsole.MarkupLine("[bold]First-run admin setup[/]");
                AnsiConsole.MarkupLine("[grey]The web panel needs an admin password before continuing.[/]");
                AnsiConsole.MarkupLine("[grey](Set [/][yellow]webPassword[/][grey] to empty string in config to disable -- local dev only.)[/]");
                string pw1, pw2;
                while (true)
                {
                    pw1 = AnsiConsole.Prompt(new TextPrompt<string>("Admin password (min 8 chars):").Secret());
                    if (pw1.Length < 8) { AnsiConsole.MarkupLine("[red]Too short.[/]"); continue; }
                    pw2 = AnsiConsole.Prompt(new TextPrompt<string>("Confirm:").Secret());
                    if (pw1 != pw2) { AnsiConsole.MarkupLine("[red]Doesn't match.[/]"); continue; }
                    break;
                }
                config.WebPassword = pw1;
                config.Save(configPath);
                AnsiConsole.MarkupLine("[green]✓[/] Admin password saved.");
                AnsiConsole.MarkupLine("");
            }
        }
        else if (!IsLocalhostBind(config) && config.WebPassword.Length == 0)
        {
            AnsiConsole.MarkupLine("[red]Refusing to bind on a non-localhost address with webPassword disabled (\"\").[/]");
            AnsiConsole.MarkupLine("[red]Either set a real password or change [yellow]webHost[/] to [yellow]localhost[/].[/]");
            return 1;
        }

        // ────────── Auto-setup phase: bring the server install up to spec ──────────
        var ok = await EnsureInstalledAsync(config, configPath);
        if (!ok)
        {
            AnsiConsole.MarkupLine("[red]Auto-setup failed; cannot run server.[/]");
            return 1;
        }

        // ────────── Process + daemon phase ──────────
        var serverProc = new ServerProcess(config);
        // RCON connects lazily on first use AND reconnects after failure -- MC takes
        // ~30 s to open the RCON port, so eager-connect-once would cache a dead client.
        var rcon = new RconManager("127.0.0.1", config.RconPort, config.RconPassword);
        Action<string> srvLog = msg => AnsiConsole.MarkupLine($"[grey]{msg.EscapeMarkup()}[/]");
        var whitelistRequests = new WhitelistRequestService(config);
        var broadcaster = new Broadcaster(serverProc);
        var updater = new UpdaterService(config, configPath, serverProc, broadcaster, srvLog);
        var backup  = new BackupService(config, serverProc, broadcaster, srvLog);
        var bluemap = new BlueMapService(config, srvLog);
        var world   = new WorldService(config, serverProc, backup, broadcaster, rcon, srvLog, bluemap);
        var scheduler = new BackupScheduler(config, backup, srvLog);
        scheduler.Start();

        try
        {
            serverProc.Start();
            AnsiConsole.MarkupLine($"[green]✓[/] Started Minecraft (PID {serverProc.Pid})");
        }
        catch (Exception ex)
        {
            AnsiConsole.MarkupLine($"[red]✗ Failed to start Minecraft:[/] {ex.Message.EscapeMarkup()}");
            return 1;
        }

        serverProc.OnLogLine += line =>
        {
            var ts = line.At.ToLocalTime().ToString("HH:mm:ss");
            var color = line.IsError ? "red" : "grey";
            AnsiConsole.MarkupLine($"[{color}][[{ts}]] {line.Text.EscapeMarkup()}[/]");
        };

        // Build Kestrel host
        var builder = WebApplication.CreateBuilder();
        builder.WebHost.ConfigureKestrel(opts =>
        {
            if (IsLocalhostBind(config))
                opts.ListenLocalhost(config.WebPort);
            else
                opts.ListenAnyIP(config.WebPort);
        });
        builder.Logging.ClearProviders();

        // Per-IP rate limit on the login endpoint: 10 attempts / 60 s sliding window.
        // Without this, a weak password is bruteforce-trivial. Keyed on remote IP so
        // a single brute-forcer can't exhaust everyone's quota.
        builder.Services.AddRateLimiter(rl =>
        {
            rl.RejectionStatusCode = 429;
            rl.AddPolicy("login", ctx =>
                System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
                    ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                    _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
                    {
                        PermitLimit = 10,
                        Window = TimeSpan.FromMinutes(1),
                        QueueLimit = 0,
                    }));
            // Public friend-side whitelist request: 5/hour per IP. Keeps spam manageable
            // without making it annoying for friends who legitimately want to retry.
            rl.AddPolicy("whitelist-request", ctx =>
                System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
                    ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                    _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
                    {
                        PermitLimit = 5,
                        Window = TimeSpan.FromHours(1),
                        QueueLimit = 0,
                    }));
            // Status check is the launcher's polling endpoint -- needs a looser limit
            // so an open launcher polling status doesn't get throttled. 30/hour gives
            // ~one check every 2 minutes, far more than the launcher actually needs.
            rl.AddPolicy("whitelist-status", ctx =>
                System.Threading.RateLimiting.RateLimitPartition.GetFixedWindowLimiter(
                    ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
                    _ => new System.Threading.RateLimiting.FixedWindowRateLimiterOptions
                    {
                        PermitLimit = 30,
                        Window = TimeSpan.FromHours(1),
                        QueueLimit = 0,
                    }));
        });

        // Trust X-Forwarded-For from loopback (a local reverse proxy like Caddy or nginx).
        // Without this, the rate limiter would partition by Caddy's 127.0.0.1 connection
        // and a single brute-forcer could exhaust everyone's quota.
        builder.Services.Configure<Microsoft.AspNetCore.Builder.ForwardedHeadersOptions>(o =>
        {
            o.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor
                               | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto;
            o.KnownProxies.Add(System.Net.IPAddress.Loopback);
            o.KnownProxies.Add(System.Net.IPAddress.IPv6Loopback);
        });

        var app = builder.Build();
        app.UseForwardedHeaders();
        app.UseRateLimiter();

        // Static UI: prefer sidecar wwwroot/ for hot edits, fall back to embedded.
        var sidecar = Path.Combine(AppContext.BaseDirectory, "wwwroot");
        IFileProvider uiProvider = Directory.Exists(sidecar)
            ? new PhysicalFileProvider(sidecar)
            : new ManifestEmbeddedFileProvider(typeof(RunCommand).Assembly, "wwwroot");
        app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = uiProvider });
        app.UseStaticFiles(new StaticFileOptions { FileProvider = uiProvider });

        // BlueMap output at /map/ -- register middleware unconditionally. We
        // pre-create the directory so PhysicalFileProvider's constructor is happy
        // even before the first render.
        Directory.CreateDirectory(bluemap.WebDir);
        var bluemapFp = new PhysicalFileProvider(Path.GetFullPath(bluemap.WebDir));

        // (First-run admin setup happens early in ExecuteAsync, before the heavy
        // install + MC start, so the interactive prompt isn't drowned by log output.)

        // Auth: enforced whenever a password is set, regardless of bind. Accepts either
        //   X-Brass-Sigil-Auth header (curl / scripts) or the brass-sigil-auth cookie
        //   (browsers -- works for SSE too since EventSource can't set custom headers).
        // /api/auth/login is allowed through without auth (chicken-and-egg) but is
        // separately rate-limited to throttle brute-force attempts.
        //
        // MUST be registered BEFORE the /map static-files handlers below -- otherwise
        // those handlers short-circuit the pipeline and serve map tiles to anyone.
        // The UI shell at / is intentionally above this (login page must be reachable
        // unauthenticated); all sensitive operations go through /api/* which is gated.
        // Gate registered unconditionally so first-run mode (config.WebPassword
        // == null) is still locked down to /api/auth/setup + /api/auth/state.
        // Once the operator sets a password via the web panel, the same gate
        // tightens automatically (config.WebPassword is updated in-place).
        app.Use(async (ctx, next) =>
        {
            var path = ctx.Request.Path;
            // Only gate /api/* and /map/* -- everything else is the public UI shell.
            var gated = path.StartsWithSegments("/api") || path.StartsWithSegments("/map");
            if (!gated) { await next(); return; }

            // Always-anonymous endpoints (login, state probe, friend whitelist requests).
            if (path.StartsWithSegments("/api/auth/login")) { await next(); return; }
            if (path.StartsWithSegments("/api/auth/state")) { await next(); return; }
            if (path.StartsWithSegments("/api/whitelist/request")) { await next(); return; }
            if (path.StartsWithSegments("/api/whitelist/status")) { await next(); return; }

            // First-run mode: only /api/auth/setup is reachable until a password
            // is set. The setup endpoint refuses to overwrite an existing password.
            if (string.IsNullOrEmpty(config.WebPassword))
            {
                if (path.StartsWithSegments("/api/auth/setup")) { await next(); return; }
                ctx.Response.StatusCode = 401;
                await ctx.Response.WriteAsync("Not yet configured -- visit / in a browser to set an admin password.");
                return;
            }

            // Normal auth check (cookie or X-Brass-Sigil-Auth header).
            var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword);
            var auth = ctx.Request.Headers["X-Brass-Sigil-Auth"].ToString();
            if (string.IsNullOrEmpty(auth))
                auth = ctx.Request.Cookies["brass-sigil-auth"] ?? "";
            var authBytes = System.Text.Encoding.UTF8.GetBytes(auth);
            var ok = authBytes.Length == pwBytes.Length
                  && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(authBytes, pwBytes);
            if (!ok)
            {
                ctx.Response.StatusCode = 401;
                await ctx.Response.WriteAsync("Unauthorized");
                return;
            }
            await next();
        });

        // /map/.../live/players.json is generated dynamically per-request from RCON
        // -- pull-based, so RCON only fires while a browser tab is actually polling.
        // No server-side timer, no idle-state to manage. Same model as /api/players.
        // Must be registered BEFORE the /map static-files handler so it short-circuits
        // even if a stale players.json exists on disk from an earlier daemon version.
        const string PlayersJsonPath = "/map/maps/overworld/live/players.json";
        app.Use(async (ctx, next) =>
        {
            if (ctx.Request.Path != PlayersJsonPath) { await next(); return; }
            ctx.Response.ContentType = "application/json";
            try
            {
                var players = await BlueMapPlayers.SnapshotAsync(rcon, config.ServerDir, ctx.RequestAborted);
                await ctx.Response.WriteAsync(JsonSerializer.Serialize(new { players }), ctx.RequestAborted);
            }
            catch (OperationCanceledException) { /* client gone */ }
            catch
            {
                // Don't fail the request -- empty list is fine if RCON's having a moment.
                await ctx.Response.WriteAsync("{\"players\":[]}");
            }
        });

        // /map/assets/playerheads/<uuid>.png -- BlueMap's web UI requests these for
        // player markers. The CLI distribution doesn't write them (only the in-server
        // mod does), so on a CLI-only setup every marker falls back to assets/steve.png.
        // We lazily fetch the head from crafatar.com on first request and cache to
        // disk; subsequent requests hit static-files directly (zero outbound traffic).
        // To force a skin refresh, delete the matching <uuid>.png from
        // <bluemapDir>/web/assets/playerheads/.
        const string PlayerHeadPrefix = "/map/assets/playerheads/";
        var headHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(8) };
        headHttp.DefaultRequestHeaders.UserAgent.ParseAdd("brass-sigil-server/1.0");
        app.Use(async (ctx, next) =>
        {
            var p = ctx.Request.Path.Value;
            if (p is null
                || !p.StartsWith(PlayerHeadPrefix, StringComparison.Ordinal)
                || !p.EndsWith(".png", StringComparison.Ordinal))
            { await next(); return; }
            var uuidStr = p.Substring(PlayerHeadPrefix.Length, p.Length - PlayerHeadPrefix.Length - 4);
            if (!Guid.TryParse(uuidStr, out _)) { await next(); return; }
            var cachePath = Path.Combine(bluemap.WebDir, "assets", "playerheads", uuidStr + ".png");
            if (!File.Exists(cachePath))
            {
                try
                {
                    var bytes = await headHttp.GetByteArrayAsync(
                        $"https://crafatar.com/avatars/{uuidStr}?size=32&overlay",
                        ctx.RequestAborted);
                    Directory.CreateDirectory(Path.GetDirectoryName(cachePath)!);
                    await File.WriteAllBytesAsync(cachePath, bytes, ctx.RequestAborted);
                }
                catch (OperationCanceledException) { return; }
                catch
                {
                    // Fall through to static-files which 404s; BlueMap shows steve.png.
                    // Better UX than a 5xx -- the rest of the map keeps rendering.
                }
            }
            await next();
        });

        // BlueMap stores `.json` and `.prbm` files pre-compressed on disk as
        // `.json.gz` / `.prbm.gz`, but its web client requests them WITHOUT the
        // `.gz` suffix -- it expects the server to transparently serve the gzipped
        // variant with Content-Encoding: gzip. This middleware does that rewrite
        // BEFORE UseStaticFiles, so the existing OnPrepareResponse below handles
        // the headers as it would for a direct .gz request.
        app.Use(async (ctx, next) =>
        {
            if (ctx.Request.Path.StartsWithSegments("/map", out var rest))
            {
                var sub = rest.Value?.TrimStart('/');
                if (!string.IsNullOrEmpty(sub))
                {
                    var literal = bluemapFp.GetFileInfo(sub);
                    if (!literal.Exists)
                    {
                        var gz = bluemapFp.GetFileInfo(sub + ".gz");
                        if (gz.Exists)
                        {
                            ctx.Request.Path = "/map/" + sub + ".gz";
                        }
                    }
                }
            }
            await next();
        });

        app.UseDefaultFiles(new DefaultFilesOptions
        {
            FileProvider = bluemapFp,
            RequestPath  = "/map",
        });
        app.UseStaticFiles(new StaticFileOptions
        {
            FileProvider          = bluemapFp,
            RequestPath           = "/map",
            ServeUnknownFileTypes = true,
            DefaultContentType    = "application/octet-stream",
            OnPrepareResponse = ctx =>
            {
                // Anything ending in .gz needs Content-Encoding: gzip and a content
                // type derived from the extension BEFORE .gz (so .json.gz is JSON,
                // .prbm.gz is binary).
                var name = ctx.File.Name;
                if (!name.EndsWith(".gz", StringComparison.Ordinal)) return;
                ctx.Context.Response.Headers["Content-Encoding"] = "gzip";
                if      (name.EndsWith(".json.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/json";
                else if (name.EndsWith(".prbm.gz", StringComparison.Ordinal)) ctx.Context.Response.Headers["Content-Type"] = "application/octet-stream";
            },
        });

        // ──────────────── API endpoints ────────────────

        // ── auth endpoints ──
        // /api/auth/login sets an HttpOnly cookie so JS can never read the value;
        // this defeats cookie exfiltration via XSS. Rate-limited per IP.
        app.MapPost("/api/auth/login", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<PasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var supplied = payload?.Password ?? "";
            var pwBytes = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? "");
            var supBytes = System.Text.Encoding.UTF8.GetBytes(supplied);
            var ok = pwBytes.Length > 0
                  && supBytes.Length == pwBytes.Length
                  && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(supBytes, pwBytes);
            if (!ok)
            {
                ctx.Response.StatusCode = 401;
                await ctx.Response.WriteAsync("Wrong password");
                return;
            }
            ctx.Response.Cookies.Append("brass-sigil-auth", config.WebPassword!, new CookieOptions
            {
                HttpOnly = true,
                Secure = ctx.Request.IsHttps,
                SameSite = SameSiteMode.Strict,
                Path = "/",
            });
            await ctx.Response.WriteAsJsonAsync(new { ok = true });
        }).AllowAnonymous().RequireRateLimiting("login");

        app.MapPost("/api/auth/logout", (HttpContext ctx) =>
        {
            ctx.Response.Cookies.Delete("brass-sigil-auth");
            return Results.Json(new { ok = true });
        });

        // Anonymous probe so the panel can decide between login vs first-run
        // setup vs already-authed without triggering a 401 from a "real" call.
        app.MapGet("/api/auth/state", (HttpContext ctx) =>
        {
            var pw = config.WebPassword;
            var needsSetup = pw is null;
            var authed = false;
            if (!needsSetup && !string.IsNullOrEmpty(pw))
            {
                var cookie = ctx.Request.Cookies["brass-sigil-auth"] ?? "";
                var pwBytes = System.Text.Encoding.UTF8.GetBytes(pw);
                var ckBytes = System.Text.Encoding.UTF8.GetBytes(cookie);
                authed = ckBytes.Length == pwBytes.Length
                      && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(ckBytes, pwBytes);
            }
            return Results.Json(new { needsSetup, authed });
        });

        // First-run-only password setup. Refuses if a password is already set,
        // so an attacker can't use this endpoint to take over an existing install.
        // Rate-limited under the same bucket as /api/auth/login.
        app.MapPost("/api/auth/setup", async (HttpContext ctx) =>
        {
            if (!string.IsNullOrEmpty(config.WebPassword))
                return Results.Json(new { ok = false, error = "Password already set" }, statusCode: 409);

            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<PasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var pw = payload?.Password ?? "";
            if (pw.Length < 8)
                return Results.Json(new { ok = false, error = "Password must be at least 8 characters" }, statusCode: 400);

            config.WebPassword = pw;
            config.Save(configPath);

            // Issue the cookie immediately so the operator is logged in after setup
            // -- no second round-trip through the login form.
            ctx.Response.Cookies.Append("brass-sigil-auth", pw, new CookieOptions
            {
                HttpOnly = true,
                Secure = ctx.Request.IsHttps,
                SameSite = SameSiteMode.Strict,
                Path = "/",
            });
            return Results.Json(new { ok = true });
        }).RequireRateLimiting("login");

        app.MapPost("/api/auth/change-password", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<ChangePasswordPayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var current = payload?.Current ?? "";
            var next    = payload?.Next ?? "";
            var pwBytes  = System.Text.Encoding.UTF8.GetBytes(config.WebPassword ?? "");
            var curBytes = System.Text.Encoding.UTF8.GetBytes(current);
            var ok = pwBytes.Length > 0
                  && curBytes.Length == pwBytes.Length
                  && System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(curBytes, pwBytes);
            if (!ok) return Results.Json(new { ok = false, error = "Current password is wrong" }, statusCode: 401);
            if (next.Length < 8) return Results.Json(new { ok = false, error = "New password must be at least 8 characters" }, statusCode: 400);
            config.WebPassword = next;
            config.Save(configPath);
            // Re-issue the cookie with the new password so the current session stays logged in.
            ctx.Response.Cookies.Append("brass-sigil-auth", next, new CookieOptions
            {
                HttpOnly = true,
                Secure = ctx.Request.IsHttps,
                SameSite = SameSiteMode.Strict,
                Path = "/",
            });
            return Results.Json(new { ok = true });
        });

        app.MapGet("/api/status", () => Results.Json(new
        {
            running     = serverProc.IsRunning,
            pid         = serverProc.Pid,
            startedAt   = serverProc.StartedAt,
            uptime      = serverProc.StartedAt is { } s ? (DateTime.UtcNow - s).TotalSeconds : (double?)null,
            packVersion = ReadPackVersion(config.ServerDir),
            memoryBytes = serverProc.MemoryBytes,
            memoryMaxMB = config.MemoryMB,
            cpu         = serverProc.CpuMetrics is { } m
                ? new { current = m.Current, max = m.Max, avg = m.Avg }
                : null,
            worldSizeBytes = world.GetWorldSizeBytes(),
        }));

        app.MapGet("/api/logs", (int? since) =>
        {
            var logs = serverProc.RecentLogs();
            if (since.HasValue) logs = logs.Skip(since.Value).ToArray();
            return Results.Json(new
            {
                total = serverProc.RecentLogs().Count,
                lines = logs.Select(l => new { t = l.At, e = l.IsError, m = l.Text })
            });
        });

        // Server-Sent Events: instant push of new log lines + a replay of the recent buffer
        // on connect. Browsers (EventSource) reconnect automatically if the stream drops.
        app.MapGet("/api/logs/stream", async (HttpContext ctx, CancellationToken ct) =>
        {
            ctx.Response.Headers.ContentType  = "text/event-stream";
            ctx.Response.Headers.CacheControl = "no-cache";
            ctx.Response.Headers["X-Accel-Buffering"] = "no"; // disable nginx response buffering
            await ctx.Response.Body.FlushAsync(ct);

            var channel = System.Threading.Channels.Channel.CreateUnbounded<ServerProcess.LogLine>(
                new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false });

            void OnLine(ServerProcess.LogLine line) => channel.Writer.TryWrite(line);
            serverProc.OnLogLine += OnLine;

            try
            {
                // Replay the ring buffer first so reconnecting clients get the recent context.
                foreach (var l in serverProc.RecentLogs())
                {
                    await WriteSseLogAsync(ctx.Response, l, ct);
                }

                // Then live-stream new lines. Heartbeat every 25 s so the connection isn't
                // killed by intermediaries (nginx, browser idle timeouts).
                using var heartbeatTimer = new PeriodicTimer(TimeSpan.FromSeconds(25));
                var heartbeatTask = Task.Run(async () =>
                {
                    try { while (await heartbeatTimer.WaitForNextTickAsync(ct)) await ctx.Response.WriteAsync(": ping\n\n", ct); }
                    catch { }
                }, ct);

                while (!ct.IsCancellationRequested)
                {
                    var line = await channel.Reader.ReadAsync(ct);
                    await WriteSseLogAsync(ctx.Response, line, ct);
                }
            }
            catch (OperationCanceledException) { /* client disconnected */ }
            finally
            {
                serverProc.OnLogLine -= OnLine;
                channel.Writer.TryComplete();
            }
        });

        app.MapPost("/api/command", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var body = await sr.ReadToEndAsync();
            var payload = JsonSerializer.Deserialize<CommandPayload>(body, JsonOpts.CaseInsensitive);
            var cmd = payload?.Command?.Trim();
            if (string.IsNullOrEmpty(cmd)) return Results.BadRequest("empty command");
            await serverProc.SendInputAsync(cmd);
            return Results.Json(new { ok = true });
        });

        app.MapGet("/api/whitelist", () =>
        {
            var path = Path.Combine(Path.GetFullPath(config.ServerDir), "whitelist.json");
            if (!File.Exists(path)) return Results.Json(new { players = Array.Empty<string>() });
            try
            {
                using var doc = JsonDocument.Parse(File.ReadAllText(path));
                var names = doc.RootElement.EnumerateArray()
                    .Select(e => e.TryGetProperty("name", out var n) ? n.GetString() : null)
                    .Where(n => !string.IsNullOrWhiteSpace(n))
                    .ToArray();
                return Results.Json(new { players = names });
            }
            catch (Exception ex)
            {
                return Results.Json(new { players = Array.Empty<string>(), error = ex.Message });
            }
        });

        // ── Public whitelist request flow (friend-side, no auth) ──
        // Friends post their MC username; admin sees the queue in the panel and
        // approves/denies. The /whitelist add command itself still goes through the
        // normal admin-gated endpoint below.
        app.MapPost("/api/whitelist/request", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<WhitelistRequestPayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Username?.Trim();
            if (string.IsNullOrEmpty(name) || name.Length > 16
                || !System.Text.RegularExpressions.Regex.IsMatch(name, @"^[A-Za-z0-9_]{3,16}$"))
            {
                return Results.BadRequest(new { ok = false, error = "Username must be 3-16 letters/digits/underscore." });
            }
            var ip = ctx.Connection.RemoteIpAddress?.ToString();
            var req = whitelistRequests.Submit(name, payload?.Message, ip);
            return Results.Json(new { ok = true, status = req.Status });
        }).AllowAnonymous().RequireRateLimiting("whitelist-request");

        // Same per-IP throttle as the request endpoint -- prevents trivial enumeration
        // while still letting the launcher's polling work fine.
        app.MapGet("/api/whitelist/status", (string username) =>
        {
            var name = (username ?? "").Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
            var status = whitelistRequests.StatusFor(name);
            return Results.Json(new { ok = true, status });
        }).AllowAnonymous().RequireRateLimiting("whitelist-status");

        // ── Admin-side: list/approve/deny (auth required via global middleware) ──
        app.MapGet("/api/whitelist/requests", () =>
            Results.Json(new { requests = whitelistRequests.ListPending() }));

        app.MapPost("/api/whitelist/requests/approve", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<NamePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
            // Mark approved + actually add to MC's whitelist via stdin command.
            whitelistRequests.MarkApproved(name);
            await serverProc.SendInputAsync($"whitelist add {name}");
            // Remove from the pending file once it's actually in the whitelist.
            whitelistRequests.Remove(name);
            return Results.Json(new { ok = true });
        });

        app.MapPost("/api/whitelist/requests/deny", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<NamePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing username." });
            whitelistRequests.MarkDenied(name);
            return Results.Json(new { ok = true });
        });

        app.MapPost("/api/whitelist/add", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<NamePayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name");
            await serverProc.SendInputAsync($"whitelist add {name}");
            return Results.Json(new { ok = true });
        });

        app.MapPost("/api/whitelist/remove", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<NamePayload>(await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest("empty name");
            await serverProc.SendInputAsync($"whitelist remove {name}");
            return Results.Json(new { ok = true });
        });

        app.MapGet("/api/players", async () =>
        {
            try
            {
                var resp = await rcon.SendCommandAsync("list");
                var (count, names) = ParsePlayerList(resp);
                return Results.Json(new { online = count, players = names });
            }
            catch (Exception ex)
            {
                return Results.Json(new { online = -1, players = Array.Empty<string>(), error = ex.Message });
            }
        });

        app.MapPost("/api/server/stop", async () =>
        {
            await serverProc.StopAsync();
            return Results.Json(new { ok = true });
        });

        app.MapPost("/api/server/start", () =>
        {
            var started = serverProc.Start();
            return Results.Json(new { ok = started });
        });

        // ── Server settings (server.properties bridge) ──
        app.MapGet("/api/server/settings", () =>
        {
            var svc = new ServerPropertiesService(config);
            return Results.Json(new
            {
                values = svc.ReadEditable(),
                editableKeys = ServerPropertiesService.EditableKeys.ToArray(),
                running = serverProc.IsRunning,
            });
        });

        app.MapPost("/api/server/settings", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<Dictionary<string, string>>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            if (payload is null || payload.Count == 0)
                return Results.BadRequest(new { ok = false, error = "Empty payload." });
            var svc = new ServerPropertiesService(config);
            svc.Update(payload);
            return Results.Json(new { ok = true, restartRequired = serverProc.IsRunning });
        });

        // ── Daemon config (safe subset of server-config.json) ──
        // Lets the panel edit things that live in our own config, not server.properties:
        // memoryMB (the JVM -Xmx). Sensitive fields (passwords, manifestUrl, ports) are
        // intentionally not exposed here -- those still need direct file edits + restart.
        app.MapGet("/api/daemon/config", () => Results.Json(new
        {
            memoryMB = config.MemoryMB,
        }));

        app.MapPost("/api/daemon/config", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            if (payload is null || payload.Count == 0)
                return Results.BadRequest(new { ok = false, error = "Empty payload." });

            var changed = false;

            if (payload.TryGetValue("memoryMB", out var memEl))
            {
                int memMB;
                if (memEl.ValueKind == JsonValueKind.Number) memMB = memEl.GetInt32();
                else if (!int.TryParse(memEl.GetString(), out memMB))
                    return Results.BadRequest(new { ok = false, error = "memoryMB must be a number" });

                // Sanity bounds. MC needs at least 1 GB; absolute cap of 64 GB protects against
                // a fat-fingered entry that would refuse to start (JVM allocates Xmx up front).
                if (memMB < 1024) return Results.BadRequest(new { ok = false, error = "memoryMB must be >= 1024" });
                if (memMB > 65536) return Results.BadRequest(new { ok = false, error = "memoryMB must be <= 65536" });

                if (config.MemoryMB != memMB)
                {
                    config.MemoryMB = memMB;
                    changed = true;
                }
            }

            if (changed) config.Save(configPath);
            // Memory changes always require a restart -- the JVM can't grow -Xmx at runtime.
            return Results.Json(new { ok = true, restartRequired = changed && serverProc.IsRunning });
        });

        // ── BlueMap (manual render) ──
        app.MapGet("/api/map/status", () => Results.Json(new
        {
            inProgress  = bluemap.State.InProgress,
            phase       = bluemap.State.Phase,
            error       = bluemap.State.Error,
            startedAt   = bluemap.State.StartedAt,
            finishedAt  = bluemap.State.FinishedAt,
            exitCode    = bluemap.State.ExitCode,
            lastLogLine = bluemap.State.LastLogLine,
            hasOutput   = bluemap.HasRendered,
        }));

        app.MapPost("/api/map/render", () =>
        {
            var started = bluemap.StartRender();
            return Results.Json(new { ok = started, error = started ? null : "Render already in progress." });
        });

        app.MapPost("/api/map/cancel", () =>
        {
            var cancelled = bluemap.CancelRender();
            return Results.Json(new { ok = cancelled, error = cancelled ? null : "No render in progress." });
        });

        // Convenience: stop+start as one call so the panel can do it from a button.
        app.MapPost("/api/server/restart", async () =>
        {
            if (serverProc.IsRunning) await serverProc.StopAsync();
            // Brief delay so OS has time to release file handles before relaunch.
            await Task.Delay(1500);
            var ok = serverProc.Start();
            return Results.Json(new { ok });
        });

        // ── Updater ──
        app.MapGet("/api/update/status", async () =>
        {
            // Quick check (refreshes State.CurrentVersion / AvailableVersion as side-effect).
            var check = await updater.CheckAsync();
            return Results.Json(new
            {
                inProgress = updater.State.InProgress,
                phase      = updater.State.Phase,
                countdownTotal     = updater.State.CountdownTotal,
                countdownRemaining = updater.State.CountdownRemaining,
                current     = check.Current,
                available   = check.Available,
                needsUpdate = check.NeedsUpdate,
                error       = updater.State.Error ?? check.Error,
            });
        });

        app.MapPost("/api/update/start", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<UpdateStartPayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var delay = Math.Clamp(payload?.DelaySeconds ?? 300, 0, 3600);

            // Run the update in the background -- don't block the HTTP response on the
            // 5-minute countdown. The panel polls /api/update/status for progress.
            _ = Task.Run(() => updater.StartAsync(delay));

            return Results.Accepted("/api/update/status", new { ok = true, delaySeconds = delay });
        });

        app.MapPost("/api/update/cancel", () =>
        {
            var cancelled = updater.TryCancel();
            return Results.Json(new { ok = cancelled });
        });

        // On-demand seed fetch -- not part of /api/status polling because the
        // seed only changes on wipe, no need to re-query RCON every poll.
        app.MapGet("/api/world/seed", async (CancellationToken ct) =>
        {
            var seed = await world.GetCurrentSeedAsync(ct);
            return Results.Json(new { seed });
        });

        app.MapPost("/api/world/wipe", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<WipePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            if (!string.Equals(payload?.Confirm, "WIPE", StringComparison.Ordinal))
                return Results.BadRequest(new { ok = false, error = "Type WIPE to confirm." });
            var doBackup = payload!.Backup ?? true;
            var seedMode = (payload.SeedMode ?? "random").ToLowerInvariant() switch
            {
                "keep"   => WorldService.SeedMode.Keep,
                "custom" => WorldService.SeedMode.Custom,
                _        => WorldService.SeedMode.Random,
            };
            var opts = new WorldService.WipeOptions(doBackup, seedMode, payload.CustomSeed);
            var result = await world.WipeWorldAsync(opts);
            return Results.Json(new { ok = result.Ok, backupName = result.BackupName, seedUsed = result.SeedUsed, error = result.Error });
        });

        // ── Backups ──
        app.MapGet("/api/backup/list", () =>
        {
            var list = backup.List();
            return Results.Json(new
            {
                dir         = backup.BackupDir,
                backups     = list,
                schedule    = config.BackupSchedule,
                description = scheduler.Describe(),
                keep        = config.BackupKeep,
                nextRun     = scheduler.NextRun(),
            });
        });

        app.MapPost("/api/backup/schedule", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<BackupSchedulePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            // Empty string clears the schedule (disables). Otherwise the BackupScheduler
            // accepts: "HH:mm", comma-separated "HH:mm,HH:mm,...", or "every Nh"/"every Nm".
            var newSchedule = payload?.Schedule;
            if (!string.IsNullOrWhiteSpace(newSchedule))
            {
                // Probe by parsing -- the scheduler's Describe will return "Disabled" for invalid.
                var trial = newSchedule.Trim().ToLowerInvariant();
                bool ok;
                if (System.Text.RegularExpressions.Regex.IsMatch(trial,
                    @"^every\s+(\d+)\s*(h|hr|hrs|hour|hours|m|min|mins|minute|minutes)$"))
                {
                    ok = true;
                }
                else
                {
                    ok = trial.Split(',', StringSplitOptions.RemoveEmptyEntries)
                              .All(tok => TimeOnly.TryParse(tok.Trim(), out _));
                }
                if (!ok)
                    return Results.BadRequest(new { ok = false, error = "Schedule must be HH:mm, HH:mm,HH:mm, 'every Nh', or 'every Nm' (or empty to disable)." });
            }
            if (payload?.Keep is { } k && k < 1)
                return Results.BadRequest(new { ok = false, error = "keep must be >= 1." });

            if (newSchedule != null) config.BackupSchedule = string.IsNullOrWhiteSpace(newSchedule) ? null : newSchedule;
            if (payload?.Keep is { } kk) config.BackupKeep = kk;
            config.Save(configPath);
            scheduler.Reload();
            return Results.Json(new { ok = true, schedule = config.BackupSchedule, keep = config.BackupKeep, nextRun = scheduler.NextRun() });
        });

        app.MapPost("/api/backup/create", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<BackupCreatePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var result = await backup.CreateAsync(payload?.Reason);
            return Results.Json(new { ok = result.Ok, name = result.Name, sizeBytes = result.SizeBytes, error = result.Error });
        });

        app.MapPost("/api/backup/restore", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<BackupNamePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." });
            var (ok, error) = await backup.RestoreAsync(name);
            return Results.Json(new { ok, error });
        });

        app.MapPost("/api/backup/delete", async (HttpContext ctx) =>
        {
            using var sr = new StreamReader(ctx.Request.Body);
            var payload = JsonSerializer.Deserialize<BackupNamePayload>(
                await sr.ReadToEndAsync(), JsonOpts.CaseInsensitive);
            var name = payload?.Name?.Trim();
            if (string.IsNullOrEmpty(name)) return Results.BadRequest(new { ok = false, error = "Missing backup name." });
            var (ok, error) = backup.Delete(name);
            return Results.Json(new { ok, error });
        });

        AnsiConsole.MarkupLine($"[green]✓[/] Web UI:  [blue]http://{config.WebHost}:{config.WebPort}/[/]");

        // Intercept Ctrl+C / SIGTERM so the Java subprocess is stopped cleanly
        // before we exit. The Windows Job Object is a last-resort safety net for
        // the cases this can't catch (Task Manager kill, BSOD).
        Console.CancelKeyPress += (_, e) =>
        {
            e.Cancel = true; // we'll exit ourselves after stopping MC
            AnsiConsole.MarkupLine("[yellow]Shutting down...[/]");
            // 60 s headroom matches the systemd unit's TimeoutStopSec -- populated
            // worlds with terrain mods + DH LOD writes + C2ME chunk flushes can take
            // 30-50 s to finish saving cleanly. Tight timeout = risk of force-kill
            // mid-flush. If MC's done sooner, this returns sooner.
            try { bluemap.Dispose(); } catch { } // kill any in-flight render before MC stop
            try { serverProc.StopAsync(TimeSpan.FromSeconds(60)).GetAwaiter().GetResult(); } catch { }
            try { app.StopAsync(TimeSpan.FromSeconds(5)).GetAwaiter().GetResult(); } catch { }
        };
        AppDomain.CurrentDomain.ProcessExit += (_, _) =>
        {
            try { bluemap.Dispose(); } catch { }
            try { serverProc.StopAsync(TimeSpan.FromSeconds(10)).GetAwaiter().GetResult(); } catch { }
        };

        // Auto-open browser for Windows interactive launches (double-click experience).
        if (OperatingSystem.IsWindows() && !Console.IsInputRedirected)
        {
            _ = Task.Run(async () =>
            {
                await Task.Delay(2500);
                try { Process.Start(new ProcessStartInfo { FileName = $"http://{config.WebHost}:{config.WebPort}/", UseShellExecute = true }); }
                catch { /* user can browse manually */ }
            });
        }

        await app.RunAsync();
        await serverProc.StopAsync();
        scheduler.Dispose();
        bluemap.Dispose();
        rcon.Dispose();
        return 0;
    }

    /// <summary>
    /// Brings the install up to spec before launching: validates Java, accepts EULA,
    /// syncs mods from the manifest, and runs the NeoForge server installer if needed.
    /// Idempotent and quick when nothing needs doing.
    /// </summary>
    private static async Task<bool> EnsureInstalledAsync(ServerConfig config, string configPath)
    {
        var serverDir = Path.GetFullPath(config.ServerDir);
        Directory.CreateDirectory(serverDir);

        // 0. Java version pre-flight -- MC 1.21.1 / NeoForge 21.1.x require Java 21+.
        // We try in order: configured javaPath -> bundled at server/java/ -> system PATH.
        // If none yield Java 21+, auto-download Adoptium Temurin JRE 21 to server/java/.
        var javaInstaller = new JavaInstaller();
        var resolvedJava = await ResolveOrInstallJavaAsync(config, configPath, javaInstaller, serverDir);
        if (resolvedJava is null) return false;
        config.JavaPath = resolvedJava.Value.Path;
        AnsiConsole.MarkupLine($"[green]✓[/] Java {resolvedJava.Value.Major} ({resolvedJava.Value.Vendor}) -- {config.JavaPath}");

        // 1. EULA
        var eulaPath = Path.Combine(serverDir, "eula.txt");
        var eulaAccepted = File.Exists(eulaPath) && File.ReadAllText(eulaPath).Contains("eula=true");
        if (!eulaAccepted && !config.AcceptEula)
        {
            AnsiConsole.MarkupLine("[bold]The Minecraft EULA has not been accepted yet.[/]");
            AnsiConsole.MarkupLine("Read it: [blue]https://aka.ms/MinecraftEULA[/]");
            if (Console.IsInputRedirected)
            {
                AnsiConsole.MarkupLine("[red]Re-run with --accept-eula or set acceptEula:true in server-config.json.[/]");
                return false;
            }
            if (!AnsiConsole.Confirm("Do you accept the Minecraft EULA?", false))
            {
                AnsiConsole.MarkupLine("[red]EULA not accepted, exiting.[/]");
                return false;
            }
            config.AcceptEula = true;
            config.Save(configPath);
        }
        if (!File.Exists(eulaPath))
        {
            await File.WriteAllTextAsync(eulaPath,
                "# Generated by brass-sigil-server\n" +
                "# By setting eula=true you accept https://aka.ms/MinecraftEULA\n" +
                "eula=true\n");
        }

        // 2. server.properties (only if missing)
        var propsPath = Path.Combine(serverDir, "server.properties");
        if (!File.Exists(propsPath))
        {
            if (string.IsNullOrEmpty(config.RconPassword))
            {
                config.RconPassword = Convert.ToHexString(Guid.NewGuid().ToByteArray()).ToLowerInvariant();
                config.Save(configPath);
            }
            await File.WriteAllTextAsync(propsPath,
                $"motd=Brass & Sigil\n" +
                $"gamemode=survival\n" +
                $"difficulty=normal\n" +
                $"online-mode=true\n" +
                $"white-list=true\n" +
                $"enforce-whitelist=true\n" +
                $"max-players=20\n" +
                $"view-distance=12\n" +
                $"simulation-distance=10\n" +
                $"enable-rcon=true\n" +
                $"rcon.port={config.RconPort}\n" +
                $"rcon.password={config.RconPassword}\n" +
                $"broadcast-rcon-to-ops=false\n");
            AnsiConsole.MarkupLine("[grey]Wrote default server.properties[/]");
        }

        // server-icon.png -- extract from embedded resource if missing. Survives wipes
        // (lives at server root, not inside world/) and persists across pack updates.
        var iconPath = Path.Combine(serverDir, "server-icon.png");
        if (!File.Exists(iconPath))
        {
            try
            {
                using var stream = typeof(RunCommand).Assembly
                    .GetManifestResourceStream("BrassAndSigil.Server.Assets.server-icon.png");
                if (stream is not null)
                {
                    await using var fs = File.Create(iconPath);
                    await stream.CopyToAsync(fs);
                    AnsiConsole.MarkupLine("[grey]Placed default server-icon.png[/]");
                }
            }
            catch (Exception ex)
            {
                AnsiConsole.MarkupLine($"[grey]Couldn't write server-icon.png: {ex.Message.EscapeMarkup()}[/]");
            }
        }

        // 3. Fetch manifest + sync mods
        AnsiConsole.MarkupLine("[bold]Checking modpack...[/]");
        var sync = new ManifestSync();
        Manifest manifest;
        try
        {
            manifest = await sync.FetchManifestAsync(config.ManifestUrl);
        }
        catch (Exception ex)
        {
            AnsiConsole.MarkupLine($"[red]Could not fetch manifest:[/] {ex.Message.EscapeMarkup()}");
            return false;
        }

        var localPackVer = ReadLocalPackVersion(serverDir);
        var needsSync = !string.Equals(localPackVer, manifest.Version, StringComparison.Ordinal);
        if (needsSync)
        {
            AnsiConsole.MarkupLine(localPackVer is null
                ? $"[yellow]No pack installed yet. Syncing v{manifest.Version}...[/]"
                : $"[yellow]Pack v{localPackVer} -> v{manifest.Version}, syncing...[/]");
            var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($"  [grey]{msg.EscapeMarkup()}[/]"));
            try
            {
                var result = await sync.SyncAsync(config.ManifestUrl, serverDir, progress);
                AnsiConsole.MarkupLine($"[green]✓[/] Pack synced: {result.Downloaded} downloaded, {result.Removed} removed, {result.Skipped} client-only skipped");
            }
            catch (Exception ex)
            {
                AnsiConsole.MarkupLine($"[red]Sync failed:[/] {ex.Message.EscapeMarkup()}");
                return false;
            }
        }
        else
        {
            AnsiConsole.MarkupLine($"[green]✓[/] Pack already at v{localPackVer}");
        }

        // 4. Loader install (NeoForge only for now)
        var loader = manifest.Loader;
        if (loader is not null && loader.Type.Equals("neoforge", StringComparison.OrdinalIgnoreCase))
        {
            var nfInstaller = new NeoForgeInstaller();
            if (!nfInstaller.IsAlreadyInstalled(serverDir))
            {
                AnsiConsole.MarkupLine($"[yellow]Installing NeoForge {loader.Version}...[/]");
                var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($"  [grey]{msg.EscapeMarkup()}[/]"));
                var success = await nfInstaller.InstallAsync(loader.Version, serverDir, config.JavaPath, progress, default);
                if (!success)
                {
                    AnsiConsole.MarkupLine("[red]NeoForge install failed. Make sure 'java' is on PATH (or set javaPath in config).[/]");
                    return false;
                }
                AnsiConsole.MarkupLine("[green]✓[/] NeoForge installed");
            }
            else
            {
                AnsiConsole.MarkupLine("[green]✓[/] NeoForge already installed");
            }
        }

        return true;
    }

    private static async Task<(string Path, int Major, string Vendor)?> ResolveOrInstallJavaAsync(
        ServerConfig config, string configPath, JavaInstaller installer, string serverDir)
    {
        // 1. Try the configured javaPath as-is.
        var info = await GetJavaInfoAsync(config.JavaPath);
        if (info is { Major: >= 21 }) return (config.JavaPath, info.Value.Major, info.Value.Vendor);

        // 2. Try a previously bundled Java at server/java/.
        var bundled = installer.FindBundledJava(serverDir);
        if (bundled is not null)
        {
            var bundledInfo = await GetJavaInfoAsync(bundled);
            if (bundledInfo is { Major: >= 21 })
            {
                config.JavaPath = bundled;
                config.Save(configPath);
                return (bundled, bundledInfo.Value.Major, bundledInfo.Value.Vendor);
            }
        }

        // 3. Last resort: download Adoptium Temurin JRE 21.
        AnsiConsole.MarkupLine(info is null
            ? $"[yellow]No Java found at '[bold]{config.JavaPath}[/]'. Auto-installing Java 21...[/]"
            : $"[yellow]Found Java {info.Value.Major} ({info.Value.Vendor}) but need 21+. Auto-installing...[/]");

        var progress = new Progress<string>(msg => AnsiConsole.MarkupLine($"  [grey]{msg.EscapeMarkup()}[/]"));
        var installed = await installer.InstallJre21Async(serverDir, progress, default);
        if (installed is null)
        {
            AnsiConsole.MarkupLine("[red]Java auto-install failed.[/] Install Java 21 manually from " +
                                   "[blue]https://adoptium.net/temurin/releases/?version=21[/] and set " +
                                   $"[yellow]javaPath[/] in {configPath}.");
            return null;
        }

        var newInfo = await GetJavaInfoAsync(installed);
        if (newInfo is null || newInfo.Value.Major < 21)
        {
            AnsiConsole.MarkupLine("[red]Auto-installed Java didn't pass the version check.[/]");
            return null;
        }

        config.JavaPath = installed;
        config.Save(configPath);
        return (installed, newInfo.Value.Major, newInfo.Value.Vendor);
    }

    private static async Task<(int Major, string Vendor)?> GetJavaInfoAsync(string javaPath)
    {
        try
        {
            var psi = new ProcessStartInfo
            {
                FileName = javaPath,
                Arguments = "-version",
                RedirectStandardError = true,
                RedirectStandardOutput = true,
                UseShellExecute = false,
                CreateNoWindow = true,
            };
            var proc = Process.Start(psi);
            if (proc is null) return null;
            var stderr = await proc.StandardError.ReadToEndAsync();
            var stdout = await proc.StandardOutput.ReadToEndAsync();
            await proc.WaitForExitAsync();
            var output = stderr + "\n" + stdout;

            // First line is typically: openjdk version "21.0.7" 2025-... LTS
            //                      or: java version "1.8.0_261"
            var verMatch = System.Text.RegularExpressions.Regex.Match(output, @"version\s+""([0-9._]+)");
            if (!verMatch.Success) return null;
            var verStr = verMatch.Groups[1].Value;
            int major;
            if (verStr.StartsWith("1."))
            {
                // Old format e.g. 1.8.0 → Java 8
                var parts = verStr.Split('.');
                major = parts.Length > 1 && int.TryParse(parts[1], out var m) ? m : 0;
            }
            else
            {
                var parts = verStr.Split('.');
                major = int.TryParse(parts[0], out var m) ? m : 0;
            }

            string vendor = "OpenJDK";
            if (output.Contains("Microsoft", StringComparison.OrdinalIgnoreCase))   vendor = "Microsoft";
            else if (output.Contains("Temurin", StringComparison.OrdinalIgnoreCase)) vendor = "Temurin";
            else if (output.Contains("Zulu", StringComparison.OrdinalIgnoreCase))    vendor = "Azul Zulu";
            else if (output.Contains("Oracle", StringComparison.OrdinalIgnoreCase))  vendor = "Oracle";
            else if (output.Contains("GraalVM", StringComparison.OrdinalIgnoreCase)) vendor = "GraalVM";

            return (major, vendor);
        }
        catch
        {
            return null;
        }
    }

    private static string? TrySuggestJava21OnWindows()
    {
        if (!OperatingSystem.IsWindows()) return null;
        var roots = new[]
        {
            @"C:\Program Files\Java",
            @"C:\Program Files\Eclipse Adoptium",
            @"C:\Program Files\Microsoft",
            @"C:\Program Files\Zulu",
            @"C:\Program Files\BellSoft",
        };
        foreach (var root in roots)
        {
            if (!Directory.Exists(root)) continue;
            try
            {
                foreach (var dir in Directory.EnumerateDirectories(root))
                {
                    var name = Path.GetFileName(dir);
                    if (name.Contains("21", StringComparison.Ordinal))
                    {
                        var javaExe = Path.Combine(dir, "bin", "java.exe");
                        if (File.Exists(javaExe)) return javaExe;
                    }
                }
            }
            catch { }
        }
        return null;
    }

    private static string? ReadLocalPackVersion(string serverDir)
    {
        var path = Path.Combine(serverDir, "pack-version.json");
        if (!File.Exists(path)) return null;
        try
        {
            using var doc = JsonDocument.Parse(File.ReadAllText(path));
            return doc.RootElement.TryGetProperty("version", out var v) ? v.GetString() : null;
        }
        catch { return null; }
    }

    private static bool IsLocalhostBind(ServerConfig cfg) =>
        cfg.WebHost is "localhost" or "127.0.0.1" or "::1";

    private static (int Count, string[] Names) ParsePlayerList(string rconResp)
    {
        var colon = rconResp.IndexOf(':');
        if (colon < 0) return (0, Array.Empty<string>());
        var head = rconResp.Substring(0, colon);
        var tail = rconResp.Substring(colon + 1).Trim();
        int count = 0;
        foreach (var p in head.Split(' '))
            if (int.TryParse(p, out var n)) { count = n; break; }
        var names = string.IsNullOrWhiteSpace(tail)
            ? Array.Empty<string>()
            : tail.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
        return (count, names);
    }

    private static object? ReadPackVersion(string serverDir)
    {
        var path = Path.Combine(serverDir, "pack-version.json");
        if (!File.Exists(path)) return null;
        try { return JsonSerializer.Deserialize<JsonElement>(File.ReadAllText(path)); }
        catch { return null; }
    }

    private sealed class CommandPayload        { public string? Command  { get; set; } }
    private sealed class NamePayload           { public string? Name     { get; set; } }
    private sealed class PasswordPayload       { public string? Password { get; set; } }
    private sealed class ChangePasswordPayload { public string? Current  { get; set; } public string? Next { get; set; } }
    private sealed class UpdateStartPayload    { public int? DelaySeconds { get; set; } }
    private sealed class WipePayload {
        public string? Confirm { get; set; }
        public bool? Backup { get; set; }
        public string? SeedMode { get; set; }   // "keep" | "random" | "custom"
        public string? CustomSeed { get; set; } // only used when SeedMode == "custom"
    }
    private sealed class BackupCreatePayload   { public string? Reason { get; set; } }
    private sealed class BackupNamePayload     { public string? Name { get; set; } }
    private sealed class BackupSchedulePayload { public string? Schedule { get; set; } public int? Keep { get; set; } }
    private sealed class WhitelistRequestPayload { public string? Username { get; set; } public string? Message { get; set; } }

    private static async Task WriteSseLogAsync(HttpResponse resp, ServerProcess.LogLine line, CancellationToken ct)
    {
        var json = JsonSerializer.Serialize(new { t = line.At, e = line.IsError, m = line.Text });
        await resp.WriteAsync($"event: log\ndata: {json}\n\n", ct);
        await resp.Body.FlushAsync(ct);
    }
}
