using System.Reflection;
using Eleon.Modding;

namespace EmpyrionBridge;

// EAH-parity verbs grouped by the Empyrion Admin Helper tab they map to.
//
// Categories:
//   - Players  : kick, ban/unban, give-item, give-credits, teleport, info, inventory
//   - Factions : list, alliances, message
//   - Chat     : broadcast (server-wide), faction chat, single-player whisper
//   - Structures : list global, set-name, transfer-faction (via console)
//   - Playfields : list, load, wipe (via console), entity list
//   - Console  : raw console line, save, restart-warning
//
// Most actions either go through a typed CmdId (Request_Player_AddItem) or
// fall back to Request_ConsoleCommand with a templated string. Console
// commands are documented at https://empyrion.fandom.com/wiki/Server_commands
// — the wiki is mostly accurate as of Empyrion 1.16.x.
public static class EndpointMaps
{
    public static void MapAll(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        MapPlayers(app, pool, timeout);
        MapFactions(app, pool, timeout);
        MapChat(app, pool, timeout);
        MapStructures(app, pool, timeout);
        MapStructureOps(app, pool, timeout);
        MapPlayfields(app, pool, timeout);
        MapConsole(app, pool, timeout);
        MapBans(app, pool, timeout);
        MapEventStream(app, pool, timeout);
    }

    // ---------- SSE event stream ----------
    // GET /api/v1/events?host=&port=  — text/event-stream of unsolicited
    // Event_* packages (chat, player join/leave, structure changes, etc).
    // Each event line: data: { "cmd": "...", "seq": N, "data": {...} }\n\n
    static void MapEventStream(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        app.MapGet("/api/v1/events", async (HttpContext ctx) =>
        {
            var ep = Endpoint(ctx.Request);
            if (ep is null) { ctx.Response.StatusCode = 400; await ctx.Response.WriteAsync("missing host or port"); return; }
            var conn = await pool.GetAsync(ep.Value.host, ep.Value.port);

            ctx.Response.Headers["Content-Type"] = "text/event-stream";
            ctx.Response.Headers["Cache-Control"] = "no-cache";
            ctx.Response.Headers["X-Accel-Buffering"] = "no";

            var queue = new System.Threading.Channels.UnboundedChannelOptions { SingleReader = true, SingleWriter = false };
            var ch = System.Threading.Channels.Channel.CreateUnbounded<string>(queue);

            using var sub = conn.Subscribe(p =>
            {
                try
                {
                    var payload = System.Text.Json.JsonSerializer.Serialize(new
                    {
                        cmd = p.cmd.ToString(),
                        cmdId = (int)p.cmd,
                        seq = (int)p.seqNr,
                        clientId = p.clientId,
                        data = p.data,
                        type = p.data?.GetType()?.Name,
                    });
                    ch.Writer.TryWrite(payload);
                }
                catch { /* don't let serialization errors take down the connection */ }
            });

            // Initial heartbeat so client knows the stream is live.
            await ctx.Response.WriteAsync("event: ready\ndata: {}\n\n");
            await ctx.Response.Body.FlushAsync();

            var heartbeat = TimeSpan.FromSeconds(15);
            using var hbCts = new CancellationTokenSource();
            var hbTask = Task.Run(async () =>
            {
                try
                {
                    while (!hbCts.IsCancellationRequested)
                    {
                        await Task.Delay(heartbeat, hbCts.Token);
                        ch.Writer.TryWrite(":heartbeat");
                    }
                }
                catch (TaskCanceledException) { }
            });

            try
            {
                await foreach (var msg in ch.Reader.ReadAllAsync(ctx.RequestAborted))
                {
                    if (msg.StartsWith(":")) await ctx.Response.WriteAsync(msg + "\n\n");
                    else await ctx.Response.WriteAsync("data: " + msg + "\n\n");
                    await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
                }
            }
            catch (OperationCanceledException) { /* client went away */ }
            finally
            {
                hbCts.Cancel();
                ch.Writer.TryComplete();
            }
        });
    }

    static (string host, int port)? Endpoint(HttpRequest r)
    {
        var h = r.Query["host"].FirstOrDefault();
        var p = r.Query["port"].FirstOrDefault();
        if (string.IsNullOrEmpty(h) || !int.TryParse(p, out var pi)) return null;
        return (h, pi);
    }

    static async Task<(BridgeConnection conn, IResult? err)> Connect(BridgePool pool, HttpRequest r)
    {
        var ep = Endpoint(r);
        if (ep is null) return (null!, Results.BadRequest(new { error = "missing host or port" }));
        return (await pool.GetAsync(ep.Value.host, ep.Value.port), null);
    }

    // ---------- Players ----------
    static void MapPlayers(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // Already covered in Program.cs: GET /api/v1/players, /api/v1/players/{id}

        app.MapPost("/api/v1/players/{id:int}/kick", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var reason = (await sr.ReadToEndAsync()).Trim();
            if (string.IsNullOrEmpty(reason)) reason = "kicked by admin";
            // Use console verb — there is no typed Request_Kick.
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"kick {id} '{reason.Replace("'", "")}'"));
            return Results.Json(new { ok = true, sent = $"kick {id} '{reason}'" });
        });

        app.MapPost("/api/v1/players/{id:int}/ban", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            // Two duration shapes supported:
            //   ?dur=<string>  — passed verbatim to Empyrion (e.g. "30s", "5m", "7h", "30d", "1y", "0" for permanent)
            //   ?days=N        — legacy integer days, kept for back-compat
            // dur takes precedence when both are set.
            var rawDur = r.Query["dur"].FirstOrDefault();
            string dur;
            if (!string.IsNullOrWhiteSpace(rawDur))
            {
                // Allow only [0-9smhdy] to keep injection out of the console verb.
                var clean = new string(rawDur.Where(c => char.IsDigit(c) || c is 's' or 'm' or 'h' or 'd' or 'y').ToArray());
                if (string.IsNullOrEmpty(clean)) return Results.BadRequest(new { error = "dur must be like 30s / 5m / 7h / 30d / 1y / 0" });
                dur = clean;
            }
            else
            {
                var days = int.TryParse(r.Query["days"].FirstOrDefault(), out var d) ? d : 1;
                dur = $"{days}d";
            }
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban add {id} {dur}"));
            return Results.Json(new { ok = true, sent = $"ban add {id} {dur}" });
        });

        app.MapPost("/api/v1/players/{id:int}/unban", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"ban remove {id}"));
            return Results.Json(new { ok = true, sent = $"ban remove {id}" });
        });

        // Set Empyrion's per-player role. Wraps the
        // `setrole "<playerName>" <role>` console verb — Empyrion's setrole
        // looks up by name (or steam id), NOT by entity id, so we resolve
        // the entity id → name via a quick PlayerInfo round-trip first.
        // Sending `setrole <entityId> <role>` silently fails (Empyrion
        // returns no error but the permission level never changes).
        app.MapPost("/api/v1/players/{id:int}/set-role", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var role = (r.Query["role"].FirstOrDefault() ?? "").Trim().ToLowerInvariant();
            if (role is not ("admin" or "gamemaster" or "moderator" or "player"))
                return Results.BadRequest(new { error = "role must be one of: admin, gamemaster, moderator, player" });
            var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
                CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
            var name = (info?.playerName ?? "").Replace("\"", "");
            if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
            var cmd = $"setrole \"{name}\" {role}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, sent = cmd, id, playerName = name, role });
        });

        app.MapPost("/api/v1/players/{id:int}/give-credits", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var body = (await sr.ReadToEndAsync()).Trim();
            if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (credits to add; negative subtracts)" });
            conn.SendFireAndForget(CmdId.Request_Player_AddCredits, new IdCredits { id = id, credits = amount });
            return Results.Json(new { ok = true, id, credits = amount });
        });

        app.MapPost("/api/v1/players/{id:int}/set-credits", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var body = (await sr.ReadToEndAsync()).Trim();
            if (!double.TryParse(body, out var amount)) return Results.BadRequest(new { error = "body must be a number (absolute credits)" });
            conn.SendFireAndForget(CmdId.Request_Player_SetCredits, new IdCredits { id = id, credits = amount });
            return Results.Json(new { ok = true, id, credits = amount });
        });

        // Give an item to a player. We use Empyrion's `give "<name>" <itemId> <count>`
        // console command rather than Request_Player_AddItem because the typed
        // RPC requires a slotIdx — without one the API defaults to slot 0 in the
        // toolbar, which is normally occupied (starter pickaxe etc), and Empyrion
        // silently refuses the overwrite. The console command auto-places into
        // the first free bag slot, the natural "give-me-this-item" behavior.
        // Caller can still pass slotIdx explicitly to use the typed path.
        app.MapPost("/api/v1/players/{id:int}/give-item", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            // Body: { "itemId": 4307, "count": 1, "slotIdx": 0|null, "ammo": 0, "decay": 0 }
            var body = await JsonContent.ReadAsync<GiveItemReq>(r);
            if (body is null || body.itemId <= 0) return Results.BadRequest(new { error = "itemId required" });
            var count = body.count <= 0 ? 1 : body.count;

            // Path A: caller pinned a specific slot — go through typed RPC.
            if (body.slotIdx.HasValue)
            {
                var stack = new ItemStack
                {
                    id = body.itemId,
                    count = count,
                    slotIdx = body.slotIdx.Value,
                    ammo = body.ammo,
                    decay = body.decay,
                };
                conn.SendFireAndForget(CmdId.Request_Player_AddItem, new IdItemStack { id = id, itemStack = stack });
                return Results.Json(new { ok = true, id, item = stack, via = "typed" });
            }

            // Path B (default): auto-place via console — needs the player NAME.
            var info = await conn.RequestAsync<Eleon.Modding.PlayerInfo>(
                CmdId.Request_Player_Info, CmdId.Event_Player_Info, new Id(id), timeout);
            var name = (info?.playerName ?? "").Replace("\"", "");
            if (string.IsNullOrEmpty(name)) return Results.NotFound(new { error = "player not found / not spawned", id });
            var cmd = $"give \"{name}\" {body.itemId} {count}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, id, playerName = name, itemId = body.itemId, count, sent = cmd, via = "console" });
        });

        app.MapGet("/api/v1/players/{id:int}/inventory", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<Inventory>(CmdId.Request_Player_GetInventory, CmdId.Event_Player_Inventory, new Id(id), timeout);
            if (resp is null) return Results.NotFound(new { error = "no inventory for player", id });
            return Results.Json(resp);
        });

        app.MapPost("/api/v1/players/{id:int}/teleport", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            // Body: { "x":0, "y":0, "z":0, "playfield":"optional" }
            var body = await JsonContent.ReadAsync<TeleportReq>(r);
            if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
            if (!string.IsNullOrEmpty(body.playfield))
            {
                var pkt = new IdPlayfieldPositionRotation
                {
                    id = id,
                    playfield = body.playfield,
                    pos = new PVector3 { x = body.x, y = body.y, z = body.z },
                    rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
                };
                conn.SendFireAndForget(CmdId.Request_Player_ChangePlayerfield, pkt);
                return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
            }
            else
            {
                var pkt = new IdPositionRotation
                {
                    id = id,
                    pos = new PVector3 { x = body.x, y = body.y, z = body.z },
                    rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
                };
                conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
                return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
            }
        });
    }

    // ---------- Factions ----------
    static void MapFactions(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // Already covered in Program.cs: GET /api/v1/factions

        app.MapGet("/api/v1/factions/alliances", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<AlliancesTable>(CmdId.Request_AlliancesAll, CmdId.Event_AlliancesAll, null, timeout);
            return Results.Json(resp ?? new AlliancesTable());
        });

        // POST /api/v1/factions/alliance-state — set alliance state between two factions
        // state mapping: ally=0, neutral=1, hostile=2 (Empyrion console verb values)
        app.MapPost("/api/v1/factions/alliance-state", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<AllianceStateReq>(r);
            if (body is null) return Results.BadRequest(new { error = "factionA, factionB, and state required" });
            int stateCode;
            switch (body.state?.Trim().ToLowerInvariant())
            {
                case "ally":    stateCode = 0; break;
                case "neutral": stateCode = 1; break;
                case "hostile": stateCode = 2; break;
                default: return Results.BadRequest(new { error = "state must be one of: ally, neutral, hostile" });
            }
            var cmd = $"setalliancestate {body.factionA} {body.factionB} {stateCode}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });
    }

    // ---------- Chat ----------
    static void MapChat(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        app.MapPost("/api/v1/chat/broadcast", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var msg = (await sr.ReadToEndAsync()).Trim();
            if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
            // prio: 0=info, 1=warning, 2=alert. Default to alert for visibility.
            var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)2;
            var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
            conn.SendFireAndForget(CmdId.Request_InGameMessage_AllPlayers, new IdMsgPrio { id = 0, msg = msg, prio = prio, time = time });
            return Results.Json(new { ok = true, msg, prio, time });
        });

        app.MapPost("/api/v1/chat/whisper/{playerId:int}", async (int playerId, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var msg = (await sr.ReadToEndAsync()).Trim();
            if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
            var prio = byte.TryParse(r.Query["prio"].FirstOrDefault(), out var pp) ? pp : (byte)0;
            var time = float.TryParse(r.Query["time"].FirstOrDefault(), out var tt) ? tt : 8.0f;
            conn.SendFireAndForget(CmdId.Request_InGameMessage_SinglePlayer, new IdMsgPrio { id = playerId, msg = msg, prio = prio, time = time });
            return Results.Json(new { ok = true, playerId, msg });
        });

        app.MapPost("/api/v1/chat/faction/{factionId:int}", async (int factionId, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var msg = (await sr.ReadToEndAsync()).Trim();
            if (string.IsNullOrEmpty(msg)) return Results.BadRequest(new { error = "empty message" });
            conn.SendFireAndForget(CmdId.Request_InGameMessage_Faction, new IdMsgPrio { id = factionId, msg = msg, prio = 2, time = 8.0f });
            return Results.Json(new { ok = true, factionId, msg });
        });
    }

    // ---------- Structures ----------
    static void MapStructures(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // Empyrion's pattern: send Request_GlobalStructure_Update, the server
        // pushes Event_GlobalStructure_List back. We correlate by expected cmd.
        app.MapGet("/api/v1/structures", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<GlobalStructureList>(CmdId.Request_GlobalStructure_Update, CmdId.Event_GlobalStructure_List, new PString(""), timeout);
            return Results.Json(new
            {
                playfields = resp?.globalStructures?.Keys ?? Enumerable.Empty<string>(),
                count = resp?.globalStructures?.Sum(kv => kv.Value?.Count ?? 0) ?? 0,
                structures = resp?.globalStructures?.SelectMany(kv => (kv.Value ?? new List<GlobalStructureInfo>()).Select(gs => new
                {
                    playfield = kv.Key,
                    id = gs.id,
                    name = gs.name,
                    type = gs.type,
                    pos = new[] { gs.pos.x, gs.pos.y, gs.pos.z },
                    factionId = gs.factionId,
                    factionGroup = gs.factionGroup,
                    coreType = gs.coreType,
                    classNr = gs.classNr,
                    blocks = gs.cntBlocks,
                    devices = gs.cntDevices,
                    triangles = gs.cntTriangles,
                    lights = gs.cntLights,
                    fuel = gs.fuel,
                    pilotId = gs.pilotId,
                    powered = gs.powered,
                })) ?? Enumerable.Empty<object>(),
            });
        });

        app.MapPost("/api/v1/structures/{id:int}/rename", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            using var sr = new StreamReader(r.Body);
            var newName = (await sr.ReadToEndAsync()).Trim();
            if (string.IsNullOrEmpty(newName)) return Results.BadRequest(new { error = "empty name" });
            // The Mod API has Request_Entity_SetName but it requires playfield+name, not just id.
            // Easier path: console "rename ID NEW_NAME"
            var safe = newName.Replace("\"", "");
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"rename '{id}' '{safe}'"));
            return Results.Json(new { ok = true, id, name = newName });
        });

        app.MapPost("/api/v1/structures/{id:int}/destroy", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            conn.SendFireAndForget(CmdId.Request_Entity_Destroy, new Id(id));
            return Results.Json(new { ok = true, id });
        });
    }

    // ---------- Structure Operations ----------
    static void MapStructureOps(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // POST /api/v1/structures/{id:int}/faction — transfer structure to a faction
        app.MapPost("/api/v1/structures/{id:int}/faction", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<FactionReq>(r);
            if (body is null) return Results.BadRequest(new { error = "factionId required" });
            var cmd = $"faction structure {id} {body.factionId}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/structures/{id:int}/heal — heal structure to 100%
        app.MapPost("/api/v1/structures/{id:int}/heal", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var cmd = $"heal {id} 100";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/structures/{id:int}/refuel — refuel structure
        app.MapPost("/api/v1/structures/{id:int}/refuel", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var cmd = $"refuel {id}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/structures/{id:int}/set-core — set core type
        app.MapPost("/api/v1/structures/{id:int}/set-core", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<SetCoreReq>(r);
            if (body is null || body.mode is null) return Results.BadRequest(new { error = "mode required (admin|player|hardcore)" });
            var mode = body.mode.Trim().ToLowerInvariant();
            if (mode is not ("admin" or "player" or "hardcore"))
                return Results.BadRequest(new { error = "mode must be one of: admin, player, hardcore" });
            var cmd = $"setadmincore {id} {mode}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/structures/{id:int}/move — move structure (same playfield or cross-playfield)
        app.MapPost("/api/v1/structures/{id:int}/move", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<MoveStructureReq>(r);
            if (body is null) return Results.BadRequest(new { error = "x/y/z required" });
            if (!string.IsNullOrEmpty(body.playfield))
            {
                var pkt = new IdPlayfieldPositionRotation
                {
                    id = id,
                    playfield = body.playfield,
                    pos = new PVector3 { x = body.x, y = body.y, z = body.z },
                    rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
                };
                conn.SendFireAndForget(CmdId.Request_Entity_ChangePlayfield, pkt);
                return Results.Json(new { ok = true, id, playfield = body.playfield, x = body.x, y = body.y, z = body.z });
            }
            else
            {
                var pkt = new IdPositionRotation
                {
                    id = id,
                    pos = new PVector3 { x = body.x, y = body.y, z = body.z },
                    rot = new PVector3 { x = body.rx, y = body.ry, z = body.rz },
                };
                conn.SendFireAndForget(CmdId.Request_Entity_Teleport, pkt);
                return Results.Json(new { ok = true, id, x = body.x, y = body.y, z = body.z });
            }
        });

        // POST /api/v1/structures/{id:int}/save-as-blueprint — save structure as blueprint
        app.MapPost("/api/v1/structures/{id:int}/save-as-blueprint", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<SaveBlueprintReq>(r);
            var cmd = string.IsNullOrEmpty(body?.name) ? $"bp {id}" : $"bp {id} \"{body.name.Replace("\"", "")}\"";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/structures/{id:int}/regenerate — regenerate a structure (optionally with faction override)
        app.MapPost("/api/v1/structures/{id:int}/regenerate", async (int id, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var body = await JsonContent.ReadAsync<RegenerateReq>(r);
            var cmd = string.IsNullOrEmpty(body?.faction) ? $"regenerate {id}" : $"regenerate {id} {body.faction}";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });
    }

    // ---------- Playfields ----------
    static void MapPlayfields(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // Already covered in Program.cs: GET /api/v1/playfields, /api/v1/playfields/{name}/entities

        app.MapGet("/api/v1/playfields/{name}/stats", async (string name, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<PlayfieldStats>(CmdId.Request_Playfield_Stats, CmdId.Event_Playfield_Stats, new PString(name), timeout);
            return Results.Json(resp ?? new PlayfieldStats { playfield = name });
        });

        app.MapPost("/api/v1/playfields/{name}/wipe", async (string name, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            // wipe playfield POI/all/terrain/deposit
            var what = r.Query["what"].FirstOrDefault() ?? "poi";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString($"wipe '{name}' {what}"));
            return Results.Json(new { ok = true, playfield = name, scope = what });
        });

        // POST /api/v1/playfields/{name}/load — load a playfield
        app.MapPost("/api/v1/playfields/{name}/load", async (string name, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var cmd = $"loadpf '{name}'";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/playfields/{name}/unload — unload a playfield
        app.MapPost("/api/v1/playfields/{name}/unload", async (string name, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var cmd = $"unloadpf '{name}'";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });

        // POST /api/v1/playfields/{name}/reset — full playfield reset (aggressive)
        app.MapPost("/api/v1/playfields/{name}/reset", async (string name, HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var cmd = $"resetplayfield '{name}'";
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString(cmd));
            return Results.Json(new { ok = true, command = cmd });
        });
    }

    // ---------- Console ----------
    static void MapConsole(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        // Already covered in Program.cs: POST /api/v1/console (raw command)

        app.MapPost("/api/v1/save", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            // Empyrion's "saveandexit" command saves and shuts down — we don't want that.
            // The "save" alias triggers an immediate save without exit; exists in 1.16+.
            conn.SendFireAndForget(CmdId.Request_ConsoleCommand, new PString("save"));
            return Results.Json(new { ok = true, sent = "save" });
        });

        app.MapPost("/api/v1/dedi/stats", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<DediStats>(CmdId.Request_Dedi_Stats, CmdId.Event_Dedi_Stats, null, timeout);
            return Results.Json(resp ?? new DediStats());
        });
    }

    // ---------- Bans ----------
    static void MapBans(WebApplication app, BridgePool pool, TimeSpan timeout)
    {
        app.MapGet("/api/v1/banned", async (HttpRequest r) =>
        {
            var (conn, err) = await Connect(pool, r); if (err != null) return err;
            var resp = await conn.RequestAsync<BannedPlayerData>(CmdId.Request_GetBannedPlayers, CmdId.Event_BannedPlayers, null, timeout);
            return Results.Json(resp ?? new BannedPlayerData());
        });
    }
}

public class GiveItemReq { public int itemId { get; set; } public int count { get; set; } public byte? slotIdx { get; set; } public int ammo { get; set; } public int decay { get; set; } }
public class TeleportReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class FactionReq { public int factionId { get; set; } }
public class SetCoreReq { public string? mode { get; set; } }
public class MoveStructureReq { public float x { get; set; } public float y { get; set; } public float z { get; set; } public float rx { get; set; } public float ry { get; set; } public float rz { get; set; } public string? playfield { get; set; } }
public class SaveBlueprintReq { public string? name { get; set; } }
public class AllianceStateReq { public int factionA { get; set; } public int factionB { get; set; } public string? state { get; set; } }
public class RegenerateReq { public string? faction { get; set; } }

internal static class JsonContent
{
    public static async Task<T?> ReadAsync<T>(HttpRequest r) where T : class
    {
        try { return await r.ReadFromJsonAsync<T>(); }
        catch { return null; }
    }
}
