package main

import (
	"bytes"
	"context"
	"crypto/sha256"
	"embed"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log/slog"
	"net/http"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"

	"google.golang.org/protobuf/encoding/protojson"
	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/dbledeez/panel/controller/internal/ca"
	"github.com/dbledeez/panel/controller/internal/db"
	"github.com/dbledeez/panel/controller/internal/events"
	modulepkg "github.com/dbledeez/panel/pkg/module"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

//go:embed static/*
var staticFS embed.FS

// Matches what Docker accepts in a volume/container name (our instance_id
// flows into both): first char alphanumeric, then alphanumeric or _ . - .
var instanceIDRE = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`)

// httpServer hosts a thin REST+SSE surface over the same state the gRPC
// services use. Its purpose is to be dead simple to call from a browser
// (plain fetch/EventSource) so the built-in dashboard can run without a
// build step or extra tooling. The same process still exposes gRPC at
// :8443 for agents + panelctl.
type httpServer struct {
	log       *slog.Logger
	registry  *agentRegistry
	db        *db.DB
	bus       *events.Bus
	logBuf    *events.LogBuffer
	pending   *pendingRegistry
	engine    *scheduleEngine
	auth      *auth
	pair      *pairService
	ca          *ca.CA // used to derive the Steam-credential KEK
	dayzJobs    *dayzModJobManager
	arkClusters *arkClusterStore
	sevenDtdClusters *sevenDtdClusterStore
	// Optional manifest registry — controller loads modules/ at startup
	// when available so the port allocator can read declared port lists
	// without needing to round-trip the agent. nil if modules/ wasn't
	// present at boot (port allocator falls back to no-op in that case).
	modules     *modulepkg.Registry
	// adminToken gates the /admin/v1/ surface for non-loopback callers.
	// Empty disables registration of the admin mux entirely. See admin.go.
	adminToken  string
	// opnfwd is the orchestrator that creates/deletes OPNsense WGVPN port
	// forwards in lockstep with instance create/delete. nil when the
	// integration is unwired (dev / fresh install). Operator opt-in via
	// panel_settings.opnfwd_enabled.
	opnfwd      *opnfwdOrchestrator
	// freshStartJobs tracks in-progress empyrion fresh-start operations.
	freshStartJobs *freshStartManager
	// httpAddr is the address the HTTP server listens on, used for self-calls.
	httpAddr    string
	// metrics samples cluster-wide totals (players/cpu/running) into a
	// rolling in-memory ring buffer so the dashboard's overview sparklines
	// survive a page reload. nil when not started (degrades to empty history).
	metrics     *metricsSampler
	// prom is the Prometheus /metrics surface (metrics_prom.go). nil in
	// tests — the endpoint + middleware simply don't register then.
	prom *promMetrics
	// scaffoldModulesDir is where /api/scaffold-module writes new module
	// dirs. Empty means "modules" (the prod WorkingDirectory layout);
	// tests inject a t.TempDir so they never touch the real modules/.
	scaffoldModulesDir string
}

// mountOverridesFor merges cluster mount overrides from every cluster
// subsystem (ARK + 7DTD). An instance belongs to at most one, so at most
// one map is non-empty; merging keeps each recreate path subsystem-agnostic.
func (h *httpServer) mountOverridesFor(id string) map[string]string {
	out := map[string]string{}
	if h.arkClusters != nil {
		for k, v := range h.arkClusters.mountOverridesFor(id) {
			out[k] = v
		}
	}
	if h.sevenDtdClusters != nil {
		for k, v := range h.sevenDtdClusters.mountOverridesFor(id) {
			out[k] = v
		}
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// handler returns the root http.Handler with CORS + auth applied.
//
// Routing layout:
//
//	/login, /api/login        — public (no auth)
//	/api/me, /api/logout      — auth required (returns 401 on miss)
//	/api/*                    — auth required (401 on miss)
//	/ (dashboard HTML)        — auth required (302 to /login on miss)
//	/*.{css,js,...}           — public static assets (login page needs them)
func (h *httpServer) handler() http.Handler {
	apiMux := http.NewServeMux()
	apiMux.HandleFunc("GET /api/agents", h.getAgents)
	apiMux.HandleFunc("GET /api/metrics/history", h.handleMetricsHistory)
	apiMux.HandleFunc("PATCH /api/agents/{id}", h.patchAgent)
	apiMux.HandleFunc("DELETE /api/agents/{id}", h.deleteAgent)
	apiMux.HandleFunc("GET /api/modules", h.getModules)
	// Template Library (WI-09): browse the embedded AMP-template catalog;
	// scaffold a starter module dir from an entry (admin-only; scaffold.go).
	apiMux.HandleFunc("GET /api/template-catalog", h.handleTemplateCatalog)
	apiMux.HandleFunc("POST /api/scaffold-module", h.handleScaffoldModule)
	apiMux.HandleFunc("GET /api/instances", h.getInstances)
	apiMux.HandleFunc("POST /api/instances", h.postInstance)
	apiMux.HandleFunc("POST /api/instances/{id}/start", h.startInstance)
	apiMux.HandleFunc("POST /api/instances/{id}/stop", h.stopInstance)
	apiMux.HandleFunc("POST /api/instances/{id}/restart", h.restartInstance)
	apiMux.HandleFunc("DELETE /api/instances/{id}", h.deleteInstance)
	apiMux.HandleFunc("POST /api/instances/{id}/rcon", h.execRCON)
	apiMux.HandleFunc("POST /api/instances/{id}/ports", h.changePorts)
	apiMux.HandleFunc("POST /api/instances/{id}/env-config", h.recreateWithEnvConfig)
	// Durable, no-recreate config save: merge into config_values + re-render
	// the module's config_file templates on the agent. Applies on next boot.
	apiMux.HandleFunc("POST /api/instances/{id}/config-render", h.renderConfigDurable)
	// "Rebuild container" (Maintenance tab) reuses the env-config recreate path
	// with an empty updates set — that handler is the only one that correctly
	// preserves ConfigValues + AssignedPorts + MountOverrides on recreate (the
	// four §6 invariants). The old client-side stop→delete→POST /api/instances
	// flow dropped all three and silently reverted map + cluster + mods.
	apiMux.HandleFunc("POST /api/instances/{id}/rebuild", h.recreateWithEnvConfig)
	// One-click mod installer (RefugeBot for 7DTD today; scaffold for more).
	// Build-tag capabilities (e.g. refugebot) so the UI can hide gated features.
	apiMux.HandleFunc("GET /api/capabilities", h.handleCapabilities)
	apiMux.HandleFunc("GET /api/mods/installable", h.handleListInstallableMods)
	apiMux.HandleFunc("POST /api/instances/{id}/install-mod", h.handleInstallMod)
	apiMux.HandleFunc("POST /api/instances/{id}/install-base-mods", h.installBaseMods)
	apiMux.HandleFunc("GET /api/instances/{id}/logs/recent", h.recentLogs)
	apiMux.HandleFunc("POST /api/instances/{id}/update", h.updateInstance)
	apiMux.HandleFunc("GET /api/instances/{id}/updates", h.listUpdates)
	apiMux.HandleFunc("GET /api/instances/{id}/update-check", h.getUpdateCheck)
	apiMux.HandleFunc("POST /api/instances/{id}/update-check", h.postUpdateCheck)
	apiMux.HandleFunc("GET /api/update-checks", h.listUpdateChecks)
	apiMux.HandleFunc("GET /api/instances/{id}/backups", h.listBackups)
	apiMux.HandleFunc("POST /api/instances/{id}/backups", h.createBackup)
	apiMux.HandleFunc("POST /api/instances/{id}/backups/{bkpId}/restore", h.restoreBackup)
	apiMux.HandleFunc("DELETE /api/instances/{id}/backups/{bkpId}", h.deleteBackup)
	apiMux.HandleFunc("GET /api/instances/{id}/backups/{bkpId}/entries", h.listBackupEntries)
	apiMux.HandleFunc("GET /api/instances/{id}/backups/{bkpId}/file", h.readBackupFile)
	// Region Medic — detect + heal corrupt 7DTD .7rg regions.
	apiMux.HandleFunc("GET /api/instances/{id}/region-health", h.getRegionHealth)
	apiMux.HandleFunc("POST /api/instances/{id}/region-health/{region}/heal", h.postRegionHeal)
	apiMux.HandleFunc("POST /api/instances/{id}/region-health/mode", h.postRegionHealMode)
	apiMux.HandleFunc("GET /api/instances/{id}/files", h.filesList)
	apiMux.HandleFunc("GET /api/instances/{id}/files/read", h.filesRead)
	apiMux.HandleFunc("GET /api/instances/{id}/files/download", h.filesDownload)
	apiMux.HandleFunc("POST /api/instances/{id}/files/upload", h.filesUpload)
	apiMux.HandleFunc("POST /api/instances/{id}/files/upload-chunk", h.filesUploadChunk)
	apiMux.HandleFunc("POST /api/instances/{id}/files/extract", h.filesExtract)
	apiMux.HandleFunc("POST /api/instances/{id}/files/compress", h.filesCompress)
	apiMux.HandleFunc("POST /api/instances/{id}/files/rename", h.filesRename)
	apiMux.HandleFunc("POST /api/instances/{id}/ark/save-restore", h.arkSaveRestore)
	apiMux.HandleFunc("POST /api/instances/{id}/files", h.filesWrite)
	apiMux.HandleFunc("DELETE /api/instances/{id}/files", h.filesDelete)
	apiMux.HandleFunc("GET /api/schedules", h.getSchedules)
	apiMux.HandleFunc("GET /api/discord/channels", h.getDiscordChannels)
	apiMux.HandleFunc("POST /api/schedules", h.postSchedule)
	apiMux.HandleFunc("PATCH /api/schedules/{id}", h.patchSchedule)
	apiMux.HandleFunc("DELETE /api/schedules/{id}", h.deleteSchedule)
	apiMux.HandleFunc("POST /api/schedules/{id}/enable", h.enableSchedule)
	apiMux.HandleFunc("POST /api/schedules/{id}/disable", h.disableSchedule)
	apiMux.HandleFunc("GET /api/events", h.streamEvents)
	apiMux.HandleFunc("GET /api/me", h.auth.handleMe)
	apiMux.HandleFunc("POST /api/logout", h.auth.handleLogout)
	apiMux.HandleFunc("POST /api/change-password", h.auth.handleChangePassword)
	// opnfwd integration: settings + status + reconcile.
	apiMux.HandleFunc("GET /api/admin/opnfwd", h.opnfwdGetSettings)
	apiMux.HandleFunc("PUT /api/admin/opnfwd", h.opnfwdSetSettings)
	apiMux.HandleFunc("POST /api/admin/opnfwd/test", h.opnfwdTest)
	apiMux.HandleFunc("GET /api/admin/opnfwd/forwards", h.opnfwdListForwards)
	apiMux.HandleFunc("POST /api/admin/opnfwd/reconcile", h.opnfwdReconcile)
	apiMux.HandleFunc("POST /api/admin/opnfwd/reforward/{id}", h.opnfwdReforward)
	apiMux.HandleFunc("PATCH /api/agents/{id}/lan-ip", h.opnfwdSetAgentLANIP)
	// Steam credential management (for games whose SteamCMD refuses
	// +login anonymous — DayZ, Arma, etc).
	apiMux.HandleFunc("POST /api/steam/login", h.handleSteamLogin)
	apiMux.HandleFunc("GET /api/steam/accounts", h.handleSteamList)
	apiMux.HandleFunc("DELETE /api/steam/accounts", h.handleSteamDelete)
	// Panel-user Steam linkage + Steam public profile proxy.
	apiMux.HandleFunc("GET /api/users", h.handleListUsers)
	apiMux.HandleFunc("POST /api/users", h.handleCreateSteamUser)
	apiMux.HandleFunc("POST /api/users/{id}/steam", h.handleLinkSteamID)
	apiMux.HandleFunc("DELETE /api/users/{id}/steam", h.handleUnlinkSteamID)
	apiMux.HandleFunc("GET /api/steam/profile", h.handleSteamProfileFetch)
	// Empyrion item directory — embedded vanilla ItemsConfig + BlocksConfig.
	// Used by the EAH-style give-item picker in the empyrion modal.
	apiMux.HandleFunc("GET /api/empyrion/items", h.handleEmpyrionItemsList)
	apiMux.HandleFunc("GET /api/empyrion/items/{id}", h.handleEmpyrionItemByID)
	apiMux.HandleFunc("GET /api/empyrion/items/{id}/icon.png", h.handleEmpyrionItemIcon)
	apiMux.HandleFunc("GET /api/empyrion/items/{id}/dropped-by", h.handleEmpyrionItemDroppedBy)
	// Recipe + loot catalogs (static, embedded ECF data).
	apiMux.HandleFunc("GET /api/empyrion/recipe/{name}", h.handleEmpyrionRecipe)
	apiMux.HandleFunc("GET /api/empyrion/loot", h.handleEmpyrionLootList)
	apiMux.HandleFunc("GET /api/empyrion/loot/{name}", h.handleEmpyrionLootGroup)
	// Per-instance: dedi-stats sparkline, chat log, blueprints, missions.
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/dedi-stats", h.handleEmpyrionDediStats)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/chat", h.handleEmpyrionChatLog)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/missions", h.handleEmpyrionMissions)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/blueprints", h.handleEmpyrionBlueprints)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/blueprints/{bpName}/spawn", h.handleEmpyrionBlueprintSpawn)
	// Blueprint library (DB-backed, controller-local storage).
	apiMux.HandleFunc("GET /api/empyrion/blueprint-library", h.handleEmpyrionBpLibList)
	apiMux.HandleFunc("POST /api/empyrion/blueprint-library", h.handleEmpyrionBpLibUpload)
	apiMux.HandleFunc("DELETE /api/empyrion/blueprint-library/{id}", h.handleEmpyrionBpLibDelete)
	apiMux.HandleFunc("POST /api/instances/{instId}/empyrion/blueprint-library/{bpId}/spawn", h.handleEmpyrionBpLibSpawn)
	// Galaxy / star-map view from the savegame's Sectors.yaml.
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/galaxy", h.handleEmpyrionGalaxy)
	// Empyrion bridge admin (URL + token settings, healthz probe).
	apiMux.HandleFunc("GET /api/admin/empyrion-bridge", h.empyrionBridgeGetSettings)
	apiMux.HandleFunc("PUT /api/admin/empyrion-bridge", h.empyrionBridgeSetSettings)
	apiMux.HandleFunc("POST /api/admin/empyrion-bridge/test", h.empyrionBridgeTest)
	// Empyrion scenario installer (Reforged Eden 2 etc. via SteamCMD workshop).
	// Register BEFORE the empyrion bridge catch-all so specific paths win.
	apiMux.HandleFunc("GET /api/empyrion/scenarios/presets", h.handleEmpyrionScenarioPresets)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/scenario/install", h.handleEmpyrionScenarioInstall)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/scenario/jobs/{jobId}", h.handleEmpyrionScenarioJob)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/scenarios/installed", h.handleEmpyrionScenariosInstalled)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/scenario/activate", h.handleEmpyrionScenarioActivate)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/discoveries", h.handleEmpyrionDiscoveries)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/player-summary", h.handleEmpyrionPlayerSummary)
	// Empyrion player-history (DB-backed; populated by the bridge SSE recorder).
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/players", h.handleEmpyrionHistoryPlayers)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/events", h.handleEmpyrionHistoryEvents)
	apiMux.HandleFunc("PUT /api/instances/{id}/empyrion-history/players/{playerId}/notes", h.handleEmpyrionPlayerNotesUpdate)
	// Empyrion inventory templates (global; per-instance "apply" hits the bridge).
	apiMux.HandleFunc("GET /api/empyrion/templates", h.handleEmpyrionTemplatesList)
	apiMux.HandleFunc("POST /api/empyrion/templates", h.handleEmpyrionTemplatesUpsert)
	apiMux.HandleFunc("DELETE /api/empyrion/templates/{id}", h.handleEmpyrionTemplatesDelete)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/templates/{tid}/apply/{playerId}", h.handleEmpyrionTemplateApply)
	// Coords book (saved coordinates).
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/coords", h.handleEmpyrionCoordsList)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/coords", h.handleEmpyrionCoordsCreate)
	apiMux.HandleFunc("DELETE /api/instances/{id}/empyrion/coords/{coordId}", h.handleEmpyrionCoordsDelete)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/coords/{coordId}/teleport", h.handleEmpyrionCoordsTeleport)
	// Warnings + warning rules.
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/warnings", h.handleEmpyrionWarningsList)
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/warnings", h.handleEmpyrionWarningsIssue)
	apiMux.HandleFunc("GET /api/empyrion/warning-rules", h.handleEmpyrionWarningRulesList)
	apiMux.HandleFunc("POST /api/empyrion/warning-rules", h.handleEmpyrionWarningRulesCreate)
	apiMux.HandleFunc("PATCH /api/empyrion/warning-rules/{id}", h.handleEmpyrionWarningRulesUpdate)
	apiMux.HandleFunc("DELETE /api/empyrion/warning-rules/{id}", h.handleEmpyrionWarningRulesDelete)
	// Structure dupes.
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/dupes", h.handleEmpyrionDupes)
	// Per-player profile.
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion-history/players/{playerId}/profile", h.handleEmpyrionPlayerProfile)
	// ChatBot rules (CRUD).
	apiMux.HandleFunc("GET /api/empyrion/chatbot-rules", h.handleEmpyrionChatBotRulesList)
	apiMux.HandleFunc("POST /api/empyrion/chatbot-rules", h.handleEmpyrionChatBotRulesCreate)
	apiMux.HandleFunc("PATCH /api/empyrion/chatbot-rules/{id}", h.handleEmpyrionChatBotRulesUpdate)
	apiMux.HandleFunc("DELETE /api/empyrion/chatbot-rules/{id}", h.handleEmpyrionChatBotRulesDelete)
	// Fresh start.
	apiMux.HandleFunc("POST /api/instances/{id}/empyrion/fresh-start", h.handleEmpyrionFreshStart)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/fresh-start/jobs/{jobId}", h.handleEmpyrionFreshStartJob)
	// Faction + structure activity (read-only views over empyrion_events).
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/factions/{factionId}/activity", h.handleEmpyrionFactionActivity)
	apiMux.HandleFunc("GET /api/instances/{id}/empyrion/structures/{structureId}/activity", h.handleEmpyrionStructureActivity)
	// Empyrion bridge proxy: forward any verb to /api/v1/<rest> on the bridge,
	// looking up the instance's EAH api port + agent LAN IP server-side.
	apiMux.HandleFunc("/api/instances/{id}/empyrion/{rest...}", h.handleEmpyrionProxy)

	// DayZ Central Economy XML merger (drop-in for mod types.xml snippets etc).
	apiMux.HandleFunc("GET /api/instances/{id}/dayz/xml/files", h.handleDayzXmlFiles)
	apiMux.HandleFunc("POST /api/instances/{id}/dayz/xml/preview", h.handleDayzXmlPreview)
	apiMux.HandleFunc("POST /api/instances/{id}/dayz/xml/commit", h.handleDayzXmlCommit)

	// DayZ Steam Workshop mod manager.
	apiMux.HandleFunc("GET /api/instances/{id}/dayz/mods", h.handleDayzModsList)
	apiMux.HandleFunc("POST /api/instances/{id}/dayz/mods", h.handleDayzModInstall)
	// Install-job tracking lives under /dayz/jobs (not /dayz/mods/jobs) to
	// avoid a path-pattern conflict with /dayz/mods/{folder}/xml — both
	// would otherwise look ambiguous to net/http's new routing engine.
	apiMux.HandleFunc("GET /api/instances/{id}/dayz/jobs", h.handleDayzModJobList)
	apiMux.HandleFunc("GET /api/instances/{id}/dayz/jobs/{jobId}", h.handleDayzModJobGet)
	apiMux.HandleFunc("DELETE /api/instances/{id}/dayz/jobs/{jobId}", h.handleDayzModJobDelete)
	apiMux.HandleFunc("DELETE /api/instances/{id}/dayz/mods/{folder}", h.handleDayzModUninstall)
	apiMux.HandleFunc("GET /api/instances/{id}/dayz/mods/{folder}/xml", h.handleDayzModXmlScan)
	apiMux.HandleFunc("PUT /api/instances/{id}/dayz/mods/order", h.handleDayzModsReorder)
	apiMux.HandleFunc("GET /api/workshop/search", h.handleWorkshopSearch)
	apiMux.HandleFunc("GET /api/workshop/detail", h.handleWorkshopDetail)

	// Conan Exiles Steam Workshop mod manager (controller/cmd/controller/conanmods.go).
	// Async install pipeline (download → symlink → modlist update) reuses the
	// dayzModJobManager — both DayZ and Conan jobs land in the same map keyed
	// by InstanceID + job_id so cross-instance bleed is impossible.
	apiMux.HandleFunc("GET /api/instances/{id}/conan/mods", h.handleConanModsList)
	apiMux.HandleFunc("POST /api/instances/{id}/conan/mods", h.handleConanModInstall)
	apiMux.HandleFunc("DELETE /api/instances/{id}/conan/mods/{workshopId}", h.handleConanModUninstall)
	apiMux.HandleFunc("PUT /api/instances/{id}/conan/mods/order", h.handleConanModReorder)
	apiMux.HandleFunc("GET /api/instances/{id}/conan/jobs", h.handleConanModJobList)
	apiMux.HandleFunc("GET /api/instances/{id}/conan/jobs/{jobId}", h.handleConanModJobGet)
	apiMux.HandleFunc("DELETE /api/instances/{id}/conan/jobs/{jobId}", h.handleConanModJobDelete)

	// ARK: Survival Ascended cluster manager.
	apiMux.HandleFunc("GET /api/ark/clusters", h.handleArkClustersList)
	apiMux.HandleFunc("GET /api/instances/{id}/ark/cluster", h.handleArkClusterGet)
	apiMux.HandleFunc("POST /api/instances/{id}/ark/cluster", h.handleArkClusterSetup)
	apiMux.HandleFunc("POST /api/instances/{id}/ark/cluster/join", h.handleArkClusterJoin)
	apiMux.HandleFunc("DELETE /api/instances/{id}/ark/cluster", h.handleArkClusterLeave)

	// 7 Days to Die cluster manager.
	apiMux.HandleFunc("GET /api/7dtd/clusters", h.handle7dtdClustersList)
	apiMux.HandleFunc("GET /api/instances/{id}/7dtd/cluster", h.handle7dtdClusterGet)
	apiMux.HandleFunc("POST /api/instances/{id}/7dtd/cluster", h.handle7dtdClusterSetup)
	apiMux.HandleFunc("POST /api/instances/{id}/7dtd/cluster/join", h.handle7dtdClusterJoin)
	apiMux.HandleFunc("POST /api/instances/{id}/7dtd/cluster/sync-mods", h.handle7dtdClusterSyncMods)
	apiMux.HandleFunc("DELETE /api/instances/{id}/7dtd/cluster", h.handle7dtdClusterLeave)

	// ARK: Survival Ascended mod manager (CurseForge via api.curse.tools).
	apiMux.HandleFunc("GET /api/ark/mods/search", h.handleArkModSearch)
	apiMux.HandleFunc("GET /api/ark/mods/detail", h.handleArkModDetail)
	apiMux.HandleFunc("GET /api/instances/{id}/ark/mods", h.handleArkModsList)
	apiMux.HandleFunc("POST /api/instances/{id}/ark/mods", h.handleArkModInstall)
	apiMux.HandleFunc("DELETE /api/instances/{id}/ark/mods/{modId}", h.handleArkModUninstall)
	apiMux.HandleFunc("PUT /api/instances/{id}/ark/mods/order", h.handleArkModsReorder)
	apiMux.HandleFunc("POST /api/instances/{id}/ark/mods/batch", h.handleArkModsBatch)

	// 7 Days to Die mod browser — Nexus search + detail (authless
	// public endpoints), plus auto-install via the Premium API key
	// configured by PANEL_NEXUS_API_KEY env. Install fetches the zip
	// server-side and streams each entry to the agent via FsWrite —
	// no agent rebuild required.
	apiMux.HandleFunc("GET /api/7dtd/mods/search", h.handleSevenDTDModSearch)
	apiMux.HandleFunc("GET /api/7dtd/mods/detail", h.handleSevenDTDModDetail)
	apiMux.HandleFunc("GET /api/7dtd/nexus/check", h.handleSevenDTDNexusCheck)
	apiMux.HandleFunc("GET /api/7dtd/nexus/files/{modId}", h.handleSevenDTDNexusFiles)
	apiMux.HandleFunc("POST /api/instances/{id}/7dtd/nexus/install", h.handleSevenDTDNexusInstall)
	apiMux.HandleFunc("POST /api/instances/{id}/7dtd/mods/upload", h.handleSevenDTDModUpload)

	// 7DTD V3.0 SandboxCode — the 150 in-game Sandbox Options that V3.0 folds
	// into one encoded serverconfig property. Schema drives the per-option UI;
	// encode/decode round-trip through the authoritative Go codec (pkg/sandbox)
	// so the panel never needs a second copy of the encoding in JS.
	apiMux.HandleFunc("GET /api/7dtd/sandbox/schema", h.handleSevenDTDSandboxSchema)
	apiMux.HandleFunc("POST /api/7dtd/sandbox/encode", h.handleSevenDTDSandboxEncode)
	apiMux.HandleFunc("POST /api/7dtd/sandbox/decode", h.handleSevenDTDSandboxDecode)

	// Valheim mod browser — Thunderstore. (BepInEx auto-installed on first mod.)
	apiMux.HandleFunc("GET /api/valheim/mods/search", h.handleValheimModSearch)
	apiMux.HandleFunc("GET /api/valheim/mods/detail", h.handleValheimModDetail)
	apiMux.HandleFunc("GET /api/instances/{id}/valheim/mods", h.handleValheimModsList)
	apiMux.HandleFunc("POST /api/instances/{id}/valheim/mods", h.handleValheimModInstall)
	apiMux.HandleFunc("DELETE /api/instances/{id}/valheim/mods/{slug}", h.handleValheimModUninstall)
	// ValheimPlus (first-class, AMP-style): install/toggle + full cfg write.
	apiMux.HandleFunc("POST /api/instances/{id}/valheim/valheimplus/install", h.handleValheimPlusInstall)
	apiMux.HandleFunc("POST /api/instances/{id}/valheim/valheimplus/config", h.handleValheimPlusConfig)
	apiMux.HandleFunc("DELETE /api/instances/{id}/valheim/valheimplus", h.handleValheimPlusUninstall)

	sub, err := fs.Sub(staticFS, "static")
	if err != nil {
		h.log.Error("embed sub fs", "err", err)
	}
	staticHandler := http.FileServer(http.FS(sub))

	// Hashed, long-cacheable assets for the new UI (rcc.css / rcc.js were
	// split out of the new.html monolith). new.html references them with
	// ?v=__RCC_CSS_V__ / __RCC_JS_V__ placeholders; we stamp real content
	// hashes at startup so a rebuilt binary busts caches automatically while
	// the HTML shell itself stays no-store. sha8 of the file body doubles as
	// the ETag.
	sha8 := func(b []byte) string {
		sum := sha256.Sum256(b)
		return hex.EncodeToString(sum[:4])
	}
	rccCSS, cssErr := fs.ReadFile(sub, "rcc.css")
	rccJS, jsErr := fs.ReadFile(sub, "rcc.js")
	newHTML, htmlErr := fs.ReadFile(sub, "new.html")
	if cssErr != nil || jsErr != nil || htmlErr != nil {
		h.log.Error("rcc asset embed read", "css", cssErr, "js", jsErr, "html", htmlErr)
	} else {
		newHTML = bytes.ReplaceAll(newHTML, []byte("__RCC_CSS_V__"), []byte(sha8(rccCSS)))
		newHTML = bytes.ReplaceAll(newHTML, []byte("__RCC_JS_V__"), []byte(sha8(rccJS)))
	}
	// serveHashedAsset overrides the outer withCORS no-store stamp: these are
	// content-addressed via the ?v= query, so they may cache forever.
	serveHashedAsset := func(body []byte, ctype string) http.HandlerFunc {
		etag := `"` + sha8(body) + `"`
		return func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("Content-Type", ctype)
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
			w.Header().Set("ETag", etag)
			if strings.Contains(r.Header.Get("If-None-Match"), etag) {
				w.WriteHeader(http.StatusNotModified)
				return
			}
			http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(body))
		}
	}

	mux := http.NewServeMux()
	if cssErr == nil && jsErr == nil {
		mux.HandleFunc("GET /rcc.css", serveHashedAsset(rccCSS, "text/css; charset=utf-8"))
		mux.HandleFunc("GET /rcc.js", serveHashedAsset(rccJS, "text/javascript; charset=utf-8"))
	}

	// Public: login endpoints + the login page itself.
	mux.HandleFunc("POST /api/login", h.auth.handleLogin)
	// Steam OpenID — public, no session yet. The callback verifies with
	// Steam's servers before handing out a panel session cookie.
	mux.HandleFunc("GET /api/auth/steam/start", h.auth.handleSteamLoginStart)
	mux.HandleFunc("GET /api/auth/steam/callback", h.auth.handleSteamLoginCallback)
	// Link-mode (signed-in user adds Steam to their account, vs. login-mode
	// where Steam IS the auth method). Different return path so the callback
	// knows which side it's on.
	apiMux.HandleFunc("GET /api/auth/steam/link-start", h.auth.handleSteamLinkStart)
	apiMux.HandleFunc("GET /api/auth/steam/link-callback", h.auth.handleSteamLinkCallback)
	mux.HandleFunc("GET /login", func(w http.ResponseWriter, r *http.Request) {
		r2 := r.Clone(r.Context())
		r2.URL.Path = "/login.html"
		staticHandler.ServeHTTP(w, r2)
	})

	// Pair endpoints — public (agent hasn't authenticated yet). Gated
	// by the one-time token on /pair/issue. /pair/ca is just the CA cert,
	// no secret.
	if h.pair != nil {
		mux.HandleFunc("GET /api/pair/ca", h.pair.getCA)
		mux.HandleFunc("POST /api/pair/issue", h.pair.issue)
		// Admin-only (protected by auth middleware applied via apiMux).
		apiMux.HandleFunc("POST /api/admin/pair-tokens", h.pair.createToken)
		apiMux.HandleFunc("GET /api/admin/pair-tokens", h.pair.listTokens)
	}

	// Protected API: 401 on miss. Accepts panel_session cookie OR
	// X-Panel-Admin-Token header OR loopback callers without auth.
	// See admin.go:requireSessionOrAdminToken for the precise rules.
	mux.Handle("/api/", h.requireSessionOrAdminToken(true, apiMux))

	// Dashboard root: 302 to /login on miss, else serve the chosen panel UI.
	// The `panel_ui` cookie selects which front-end the root route serves:
	//   panel_ui=classic -> static/index.html (the original "classic" UI)
	//   anything else     -> static/new.html  (the redesigned "command-center"
	//                        UI — the DEFAULT as of 2026-05-31)
	// Both panels ship in the same binary and call the identical /api/* surface;
	// the cookie is the only thing that differs. The toggle lives in each panel's
	// Settings ("Classic" <-> "New panel"). Direct asset paths (/new.html,
	// /index.html) are still served by the catch-all below for completeness, but
	// the root "/" is the only path users land on, so the cookie governs the
	// experience. Keeping the selection server-side (not a client redirect) means
	// no flash of the wrong panel and one fewer round-trip.
	//
	// IMPORTANT — do NOT route this through staticHandler (http.FileServer) by
	// rewriting r.URL.Path to "/index.html". FileServer canonicalizes any path
	// ending in "/index.html" by 301-redirecting to "./" — so serving the
	// classic UI that way turns "/" into an infinite "/ -> /index.html(301) ->
	// /" loop (ERR_TOO_MANY_REDIRECTS), which only triggers for *authenticated*
	// requests (unauthenticated ones 302 to /login before reaching here, which
	// is why server-side curl looked clean). We instead read the chosen file
	// out of the embedded FS and serve its bytes directly — http.ServeContent
	// never issues a canonicalization redirect, sets the right Content-Type, and
	// the outer withCORS wrapper already stamps Cache-Control: no-store.
	rootPanel := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// New "Refuge Command Center" UI is the DEFAULT. The classic UI is
		// opt-in via panel_ui=classic (the toggle lives in each panel's
		// Settings). Any other / missing cookie value → new.html.
		file := "new.html"
		if c, err := r.Cookie("panel_ui"); err == nil && c.Value == "classic" {
			file = "index.html"
		}
		if file == "new.html" && htmlErr == nil {
			// Serve the hash-stamped copy (rcc.css/js cache-busters) built at
			// startup. Zero modtime + outer no-store keep the shell fresh.
			http.ServeContent(w, r, file, time.Time{}, bytes.NewReader(newHTML))
			return
		}
		f, err := sub.Open(file)
		if err != nil {
			// Chosen asset missing (e.g. a partial deploy): fall back to the
			// other panel, then to a hard 500 rather than a loop.
			alt := "index.html"
			if file == "index.html" {
				alt = "new.html"
			}
			if f, err = sub.Open(alt); err != nil {
				http.Error(w, "panel UI asset missing", http.StatusInternalServerError)
				return
			}
			file = alt
		}
		defer f.Close()
		seeker, ok := f.(io.ReadSeeker)
		if !ok {
			// embed.FS files implement io.Seeker; this is a defensive fallback.
			w.Header().Set("Content-Type", "text/html; charset=utf-8")
			_, _ = io.Copy(w, f)
			return
		}
		// Zero modtime => ServeContent skips Last-Modified/If-Modified-Since,
		// which is what we want for an always-fresh, no-store HTML shell.
		http.ServeContent(w, r, file, time.Time{}, seeker)
	})
	mux.Handle("GET /{$}", h.auth.requireSession(false, rootPanel))

	// Direct /new.html hits (same auth posture as the static catch-all:
	// public, like every non-root asset) must also get the hash-stamped
	// shell, not the raw placeholder copy from the embedded FS.
	if htmlErr == nil {
		mux.HandleFunc("GET /new.html", func(w http.ResponseWriter, r *http.Request) {
			http.ServeContent(w, r, "new.html", time.Time{}, bytes.NewReader(newHTML))
		})
	}

	// Out-of-band admin surface. Loopback or X-Panel-Admin-Token. Sits
	// outside requireSession so on-host operators can drive recreate-dance
	// fixes without first having a dashboard session. See admin.go.
	h.registerAdminRoutes(mux, h.adminToken)

	// Prometheus scrape endpoint. Same auth posture as /admin/v1/: loopback
	// scrapes without credentials, everything else needs X-Panel-Admin-Token.
	if h.prom != nil {
		mux.Handle("GET /metrics", h.adminAuth(h.adminToken, h.prom.handler()))
	}

	// Anything else (CSS, assets) served from static FS without auth so the
	// login page can load its own resources. index.html is served only by
	// the protected root route above.
	mux.Handle("/", staticHandler)

	// prom.middleware (nil-safe) records the request-duration histogram;
	// it skips /api/events so long-lived SSE streams don't skew it.
	return withCORS(h.prom.middleware(mux))
}

// withCORS stamps cache headers on everything and reflects CORS headers
// only for same-origin browser requests (Origin host == request host —
// covers the dashboard behind a proxy that preserves Host). Non-browser
// clients (curl, panelctl, the loopback admin surface) send no Origin and
// ignore CORS entirely; same-origin fetches don't consult these headers
// either, so this is defense-in-depth against third-party pages driving
// the API with a victim's browser. Was `*` ("no auth yet" era) — auth
// exists now, and Allow-Credentials was never set, so no cross-origin
// authenticated caller could have worked before this tightening.
func withCORS(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if origin := r.Header.Get("Origin"); origin != "" && sameOriginHost(origin, r.Host) {
			w.Header().Set("Access-Control-Allow-Origin", origin)
		}
		w.Header().Add("Vary", "Origin")
		// PATCH/DELETE were always registered on /api/agents etc.; the old
		// "GET, POST" list understated the API and would have failed any
		// legitimately-allowed preflight for them.
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, PUT, DELETE, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Upload-Id, X-Chunk-Offset, X-Total-Size, X-Is-Final, X-Preview-Role, X-Panel-Admin-Token")
		// Stop edge proxies (Cloudflare) and browsers from caching the
		// dashboard HTML/JS — we ship every change inline and stale
		// HTML loads stale JS, which masks bug fixes from operators.
		// Static assets we own (JS/CSS) get the same treatment because
		// they're a single embedded file today, not separate hashed
		// bundles.
		w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
		if r.Method == http.MethodOptions {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// sameOriginHost reports whether the Origin header's host:port equals the
// request's Host. Scheme is deliberately ignored: behind the NPM/Cloudflare
// chain the browser sees https while the controller terminates http.
func sameOriginHost(origin, host string) bool {
	rest := origin
	if i := strings.Index(rest, "://"); i >= 0 {
		rest = rest[i+3:]
	}
	// Origin never carries a path, but be tolerant.
	if i := strings.IndexByte(rest, '/'); i >= 0 {
		rest = rest[:i]
	}
	return rest != "" && strings.EqualFold(rest, host)
}

// writeJSON marshals v to JSON with proto-aware encoding when relevant.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, code, msg string) {
	writeJSON(w, status, map[string]string{"code": code, "message": msg})
}

// ---- GET /api/agents ----

type agentDTO struct {
	AgentID        string `json:"agent_id"`
	Version        string `json:"version"`
	OS             string `json:"os"`
	Arch           string `json:"arch"`
	Hostname       string `json:"hostname"`
	Label          string `json:"label,omitempty"`
	Notes          string `json:"notes,omitempty"`
	// PortRangeStart/End: the host-port window the panel allocates from
	// when creating new instances on this agent. 0,0 = use the global
	// default 7000-9999. Operator sets in the agent edit modal.
	PortRangeStart int32  `json:"port_range_start,omitempty"`
	PortRangeEnd   int32  `json:"port_range_end,omitempty"`
	// LANIP is the operator-set RFC1918 IP this agent's host listens on.
	// Used by the opnfwd integration as the forward target. Empty disables
	// auto-forward for this agent. Set via PATCH /api/agents/{id}/lan-ip.
	LANIP          string `json:"lan_ip,omitempty"`
	ConnectedAt    string `json:"connected_at"`
	LastSeen       string `json:"last_seen"`
	Connected      bool   `json:"connected"`
}

// moduleDTO is the union of what all connected agents advertise for a
// given module id, plus which agents currently have that module loaded.
type moduleDTO struct {
	ID              string            `json:"id"`
	Name            string            `json:"name"`
	Version         string            `json:"version"`
	SupportedModes  []string          `json:"supported_modes"`
	HasRCON         bool              `json:"has_rcon"`
	Authors         []string          `json:"authors,omitempty"`
	UpdateProviders []moduleProvDTO   `json:"update_providers,omitempty"`
	Agents          []string          `json:"agents"`
	// Manifest-driven UI metadata (WI-07) — enriched from the CONTROLLER's
	// own ./modules manifests (h.modules, loaded via LoadDir at startup),
	// NOT from agent handshakes. Lets the dashboard drop its hardcoded
	// per-module maps (ready patterns, ports, browseable roots, appearance),
	// keeping the inline maps only as fallback for modules missing these.
	Ports           []modulepkg.Port           `json:"ports,omitempty"`
	BrowseableRoots []modulepkg.BrowseableRoot `json:"browseable_roots,omitempty"`
	ReadyPattern    string                     `json:"ready_pattern,omitempty"`
	Appearance      *modulepkg.Appearance      `json:"appearance,omitempty"`
}

type moduleProvDTO struct {
	ID   string `json:"id"`
	Kind string `json:"kind"`
}

// getModules returns the union of modules across every connected agent.
// Aggregated by id — duplicate modules (same id on >1 agent) merge; the
// `agents` field lists which nodes each module lives on.
func (h *httpServer) getModules(w http.ResponseWriter, _ *http.Request) {
	agents := h.registry.snapshot()
	byID := map[string]*moduleDTO{}
	for _, a := range agents {
		a.mu.Lock()
		mods := a.modules
		a.mu.Unlock()
		for _, m := range mods {
			entry, ok := byID[m.Id]
			if !ok {
				entry = &moduleDTO{
					ID:             m.Id,
					Name:           m.Name,
					Version:        m.Version,
					SupportedModes: append([]string(nil), m.SupportedModes...),
					HasRCON:        m.HasRcon,
					Authors:        append([]string(nil), m.Authors...),
				}
				for _, p := range m.UpdateProviders {
					entry.UpdateProviders = append(entry.UpdateProviders, moduleProvDTO{ID: p.Id, Kind: p.Kind})
				}
				byID[m.Id] = entry
			}
			entry.Agents = append(entry.Agents, a.id)
		}
	}
	out := make([]*moduleDTO, 0, len(byID))
	for _, m := range byID {
		// Enrich with UI metadata from the controller's local ./modules
		// manifests. Agent-advertised summaries stay the source of the
		// module LIST; the local manifest supplies the display extras.
		if h.modules != nil {
			if man, ok := h.modules.Get(m.ID); ok {
				m.Ports = append([]modulepkg.Port(nil), man.Ports...)
				if man.Runtime.Docker != nil {
					m.BrowseableRoots = append([]modulepkg.BrowseableRoot(nil), man.Runtime.Docker.BrowseableRoots...)
				}
				m.ReadyPattern = man.ReadyPattern
				m.Appearance = man.Appearance
			}
		}
		out = append(out, m)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	writeJSON(w, http.StatusOK, map[string]any{"modules": out})
}

func (h *httpServer) getAgents(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"agents": h.buildAgentDTOs(r.Context())})
}

// buildAgentDTOs merges in-memory (live connections) with DB rows so offline
// agents still appear in the UI — otherwise instances owned by an offline
// agent vanish from the dashboard, which silently breaks management
// (e.g. after an agent restart under a different --agent-id). Shared by
// GET /api/agents and the SSE connect snapshot.
func (h *httpServer) buildAgentDTOs(ctx context.Context) []agentDTO {
	snap := h.registry.snapshot()
	out := make([]agentDTO, 0, len(snap))
	seen := map[string]bool{}
	// One ListAgents query supplies label/notes/port-range/LAN-IP for every
	// agent (was 3 point queries per connected agent — an N+1 on every
	// dashboard poll of this endpoint). nil db (unit tests) → live-only.
	var rows []db.AgentRow
	var rowsErr error
	if h.db != nil {
		rows, rowsErr = h.db.ListAgents(ctx)
	}
	byID := make(map[string]db.AgentRow, len(rows))
	for _, a := range rows {
		byID[a.AgentID] = a
	}
	for _, a := range snap {
		a.mu.Lock()
		dto := agentDTO{
			AgentID:     a.id,
			Version:     a.version,
			OS:          a.os,
			Arch:        a.arch,
			Hostname:    a.hostname,
			ConnectedAt: a.connectedAt.Format(time.RFC3339),
			LastSeen:    a.lastSeen.Format(time.RFC3339),
			Connected:   true,
		}
		a.mu.Unlock()
		if row, ok := byID[dto.AgentID]; ok {
			dto.Label = row.Label
			dto.Notes = row.Notes
			dto.PortRangeStart = row.PortRangeStart
			dto.PortRangeEnd = row.PortRangeEnd
			dto.LANIP = row.LANIP
		}
		out = append(out, dto)
		seen[dto.AgentID] = true
	}
	if rowsErr == nil {
		for _, a := range rows {
			if seen[a.AgentID] {
				continue
			}
			out = append(out, agentDTO{
				AgentID:        a.AgentID,
				Version:        a.Version,
				OS:             a.HostOS,
				Arch:           a.HostArch,
				Hostname:       a.Hostname,
				Label:          a.Label,
				Notes:          a.Notes,
				PortRangeStart: a.PortRangeStart,
				PortRangeEnd:   a.PortRangeEnd,
				LANIP:          a.LANIP,
				ConnectedAt:    a.FirstSeen.Format(time.RFC3339),
				LastSeen:       a.LastSeen.Format(time.RFC3339),
				Connected:      false,
			})
		}
	}
	sort.Slice(out, func(i, j int) bool {
		// Connected first, then by label/id for stable ordering.
		if out[i].Connected != out[j].Connected {
			return out[i].Connected
		}
		ki, kj := out[i].Label, out[j].Label
		if ki == "" {
			ki = out[i].AgentID
		}
		if kj == "" {
			kj = out[j].AgentID
		}
		return ki < kj
	})
	return out
}

type patchAgentReq struct {
	Label          *string `json:"label,omitempty"`
	Notes          *string `json:"notes,omitempty"`
	PortRangeStart *int32  `json:"port_range_start,omitempty"`
	PortRangeEnd   *int32  `json:"port_range_end,omitempty"`
}

func (h *httpServer) patchAgent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req patchAgentReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	// Pull current values so partial updates don't wipe the other field.
	curLabel, curNotes, _ := h.db.GetAgentMeta(r.Context(), id)
	label := curLabel
	notes := curNotes
	if req.Label != nil {
		label = *req.Label
	}
	if req.Notes != nil {
		notes = *req.Notes
	}
	if err := h.db.UpdateAgentMeta(r.Context(), id, label, notes); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	// Port range is optional — only touched if either field present.
	if req.PortRangeStart != nil || req.PortRangeEnd != nil {
		var s, e int32
		if req.PortRangeStart != nil {
			s = *req.PortRangeStart
		}
		if req.PortRangeEnd != nil {
			e = *req.PortRangeEnd
		}
		if err := h.db.SetAgentPortRange(r.Context(), id, s, e); err != nil {
			writeError(w, http.StatusBadRequest, "port_range", err.Error())
			return
		}
	}
	writeJSON(w, http.StatusOK, map[string]string{"id": id, "label": label, "notes": notes})
}

// deleteAgent removes an agent registration. Optional query param:
//
//	?force=true    cascade-delete every instance pinned to this agent
//	               (DB row + opnfwd forwards + ARK cluster scrub). Without
//	               it, the call is rejected when instances exist so the
//	               operator doesn't accidentally orphan dashboard cards.
//
// In both cases the agents row is removed and the in-memory connection
// (if any) is dropped — a still-running agent will silently re-pair on
// its next heartbeat.
func (h *httpServer) deleteAgent(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	ctx := r.Context()
	force := r.URL.Query().Get("force") == "true"

	insts, err := h.db.ListInstances(ctx, id)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	if len(insts) > 0 && !force {
		writeError(w, http.StatusConflict, "has_instances",
			fmt.Sprintf("agent %q owns %d instance(s); pass ?force=true to cascade-delete them too", id, len(insts)))
		return
	}

	// Force path: scrub each instance the same way deleteInstance(force=true)
	// would when the agent is offline — cluster registry, opnfwd, then DB row.
	// We don't try to docker-rm anything; the assumption is the agent host is
	// going away (or will be cleaned up separately) since the operator is
	// removing its registration.
	cascaded := make([]string, 0, len(insts))
	for _, inst := range insts {
		if h.arkClusters != nil {
			if cid, err := h.arkClusters.removeInstanceFromCluster(inst.InstanceID); err != nil {
				h.log.Warn("delete-agent cascade: ark-cluster scrub failed", "instance_id", inst.InstanceID, "err", err)
			} else if cid != "" {
				h.log.Info("delete-agent cascade: scrubbed ark-cluster membership", "instance_id", inst.InstanceID, "cluster_id", cid)
			}
		}
		if h.sevenDtdClusters != nil {
			if cid, err := h.sevenDtdClusters.removeInstanceFromCluster(inst.InstanceID); err != nil {
				h.log.Warn("delete-agent cascade: 7dtd-cluster scrub failed", "instance_id", inst.InstanceID, "err", err)
			} else if cid != "" {
				h.log.Info("delete-agent cascade: scrubbed 7dtd-cluster membership", "instance_id", inst.InstanceID, "cluster_id", cid)
			}
		}
		if h.opnfwd != nil {
			h.opnfwd.onInstanceDeleting(ctx, inst.InstanceID)
		}
		if err := h.db.DeleteInstance(ctx, inst.InstanceID); err != nil {
			writeError(w, http.StatusInternalServerError, "db_delete_instance",
				fmt.Sprintf("failed to delete instance %q: %s", inst.InstanceID, err.Error()))
			return
		}
		cascaded = append(cascaded, inst.InstanceID)
	}

	if err := h.db.DeleteAgent(ctx, id); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	// Also remove from in-memory if currently connected — forces the
	// live agent to re-establish (and re-insert) on its next heartbeat.
	h.registry.remove(id)

	resp := map[string]any{"id": id, "status": "removed"}
	if len(cascaded) > 0 {
		resp["cascaded_instances"] = cascaded
		h.log.Info("delete-agent cascade complete", "agent_id", id, "instance_count", len(cascaded))
	}
	writeJSON(w, http.StatusOK, resp)
}

// ---- GET /api/instances ----

type instanceDTO struct {
	InstanceID    string            `json:"instance_id"`
	AgentID       string            `json:"agent_id"`
	ModuleID      string            `json:"module_id"`
	ModuleVersion string            `json:"module_version,omitempty"`
	RunMode       string            `json:"run_mode"`
	Status        string            `json:"status"`
	LastExitCode  int32             `json:"last_exit_code,omitempty"`
	DataPath      string            `json:"data_path,omitempty"`
	Detail        string            `json:"detail,omitempty"`
	// Ports is name → host_port for the panel-allocated host bindings.
	// Frontend uses this to display the actual port (vs. manifest default)
	// in the cards and the Ports tab. Empty when the controller doesn't
	// have a manifest registry loaded (allocator was a no-op).
	Ports        map[string]uint32 `json:"ports,omitempty"`
	// ConfigValues is the persisted env-var overrides set on the
	// instance — surface them to the UI so the Game launch settings
	// dropdowns (e.g. ARK SA's SERVER_MAP) can pre-select the current
	// value rather than defaulting back to the manifest's hardcoded
	// fallback every time the operator opens the modal.
	ConfigValues  map[string]string `json:"config_values,omitempty"`
	CreatedAt     string            `json:"created_at"`
	UpdatedAt     string            `json:"updated_at"`
}

func (h *httpServer) getInstances(w http.ResponseWriter, r *http.Request) {
	out, err := h.buildInstanceDTOs(r.Context(), r.URL.Query().Get("agent_id"))
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"instances": out})
}

// buildInstanceDTOs loads instance rows into the wire DTO shape. Shared by
// GET /api/instances and the SSE connect snapshot.
func (h *httpServer) buildInstanceDTOs(ctx context.Context, agentFilter string) ([]instanceDTO, error) {
	if h.db == nil { // unit tests run without postgres
		return []instanceDTO{}, nil
	}
	rows, err := h.db.ListInstances(ctx, agentFilter)
	if err != nil {
		return nil, err
	}
	out := make([]instanceDTO, 0, len(rows))
	for _, r := range rows {
		out = append(out, instanceDTO{
			InstanceID:    r.InstanceID,
			AgentID:       r.AgentID,
			ModuleID:      r.ModuleID,
			ModuleVersion: r.ModuleVersion,
			RunMode:       r.RunMode,
			Status:        r.Status,
			LastExitCode:  r.LastExitCode,
			DataPath:      r.DataPath,
			Detail:        r.Detail,
			Ports:         r.AssignedPorts,
			ConfigValues:  r.ConfigValues,
			CreatedAt:     r.CreatedAt.Format(time.RFC3339),
			UpdatedAt:     r.UpdatedAt.Format(time.RFC3339),
		})
	}
	return out, nil
}

// ---- POST /api/instances ----

type createInstanceReq struct {
	AgentID    string `json:"agent_id"`
	InstanceID string `json:"instance_id"`
	ModuleID   string `json:"module_id"`
	Version    string `json:"version,omitempty"`
	// ConfigValues lets the create form pass module-specific bootstrap
	// settings — e.g. the ARK SA map (`SERVER_MAP`), the Minecraft
	// difficulty preset, etc. Persisted to the instance row's
	// config_values column and forwarded to the agent so the container
	// gets the right env on first boot. Empty/missing keys fall back
	// to the manifest defaults; this only ADDS or OVERRIDES.
	ConfigValues map[string]string `json:"config_values,omitempty"`
	// ARKClusterID, when set on an ark-sa create, joins the new
	// instance to an existing cluster at create time — adds it to the
	// registry, sets CLUSTER_ID env, and bind-mounts the shared
	// clusters directory. Saves the post-create stop→delete→recreate
	// dance the standalone Join Cluster button does, since the first
	// container is born with the right mount.
	ARKClusterID string `json:"ark_cluster_id,omitempty"`
	// SevenDtdClusterID is the 7DTD twin of ARKClusterID: when set on a
	// 7dtd create, joins the new instance to an existing cluster at create
	// time (registry membership + CLUSTER_ID env + the shared /cluster bind
	// mount), so the very first container is born already sharing the
	// cluster's Player save dir — no post-create stop→delete→recreate dance.
	SevenDtdClusterID string `json:"seven_dtd_cluster_id,omitempty"`
	// SevenDtdSeedMods, when true on a 7dtd cluster-join-at-create, clones the
	// cluster master's Mods set onto the new server so it plays identically —
	// the create-time twin of the Cluster tab's "seed mods from master" checkbox.
	SevenDtdSeedMods bool `json:"seven_dtd_seed_mods,omitempty"`
}

func (h *httpServer) postInstance(w http.ResponseWriter, r *http.Request) {
	var req createInstanceReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.AgentID == "" || req.InstanceID == "" || req.ModuleID == "" {
		writeError(w, http.StatusBadRequest, "missing_fields", "agent_id, instance_id, module_id are required")
		return
	}
	// Docker volume + container names allow [a-zA-Z0-9_.-], first char alphanumeric.
	// We use the instance_id inside docker volume names ("panel-$ID-serverfiles"),
	// so enforce the stricter intersection here rather than failing later with a
	// confusing docker error.
	if !instanceIDRE.MatchString(req.InstanceID) {
		writeError(w, http.StatusBadRequest, "invalid_instance_id",
			`server id must start with a letter/digit and contain only letters, digits, "_", ".", or "-" (no spaces)`)
		return
	}
	if len(req.InstanceID) > 48 {
		writeError(w, http.StatusBadRequest, "invalid_instance_id", "server id must be 48 characters or fewer")
		return
	}

	conn := h.registry.get(req.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q is not connected", req.AgentID))
		return
	}

	// Defensive scrub: if a prior instance with this id was deleted while
	// the agent was disconnected (or any other path that skipped the
	// cluster-cleanup hook), `ark-clusters.json` may still hold a stale
	// "joined" entry for this instance_id. Without this, a brand-new
	// ARK server lights up with "Already in cluster X" the moment its
	// modal opens — even though the actual container has a clean
	// per-instance volume. Always scrub on fresh create.
	if h.arkClusters != nil {
		if cid, err := h.arkClusters.removeInstanceFromCluster(req.InstanceID); err != nil {
			h.log.Warn("create: pre-create cluster scrub failed", "instance_id", req.InstanceID, "err", err)
		} else if cid != "" {
			h.log.Info("create: scrubbed leftover cluster membership from prior instance",
				"instance_id", req.InstanceID, "cluster_id", cid)
		}
	}
	if h.sevenDtdClusters != nil {
		if cid, err := h.sevenDtdClusters.removeInstanceFromCluster(req.InstanceID); err != nil {
			h.log.Warn("create: pre-create 7dtd-cluster scrub failed", "instance_id", req.InstanceID, "err", err)
		} else if cid != "" {
			h.log.Info("create: scrubbed leftover 7dtd-cluster membership from prior instance",
				"instance_id", req.InstanceID, "cluster_id", cid)
		}
	}

	// Allocate host ports BEFORE upsert + create, so the row's
	// assigned_ports is populated atomically with the create. Falls
	// through cleanly to no-op when the controller doesn't have the
	// manifest loaded (port allocation deferred to agent's local bump).
	var allocatedPorts []*panelv1.PortMap
	var assignedMap map[string]uint32
	if h.modules != nil {
		if m, ok := h.modules.Get(req.ModuleID); ok {
			ap, am, err := allocatePorts(r.Context(), h.db, req.AgentID, req.InstanceID, m, nil)
			if err != nil {
				writeError(w, http.StatusConflict, "port_alloc", err.Error())
				return
			}
			allocatedPorts = ap
			assignedMap = am
		}
	}

	// ARK cluster join at create time. Lets the operator pick an
	// existing cluster from the create form's dropdown — instead of
	// the post-create Join Cluster flow which has to stop, delete,
	// and recreate the container to swap in the bind mount. We assemble
	// the same end state (registry membership + CLUSTER_ID env +
	// mount_overrides) inline so the very first container has the
	// right shape.
	var mountOverrides map[string]string
	if req.ARKClusterID != "" && req.ModuleID == "ark-sa" && h.arkClusters != nil {
		s := h.arkClusters
		s.mu.Lock()
		reg, lerr := s.load()
		if lerr != nil {
			s.mu.Unlock()
			writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
			return
		}
		// Validate the cluster_id maps to a real registered cluster.
		var found bool
		for _, c := range reg.Clusters {
			if c.ID == req.ARKClusterID {
				found = true
				break
			}
		}
		if !found {
			s.mu.Unlock()
			writeError(w, http.StatusBadRequest, "unknown_cluster", fmt.Sprintf("ark cluster %q not found", req.ARKClusterID))
			return
		}
		reg.Members[req.InstanceID] = req.ARKClusterID
		if err := s.save(reg); err != nil {
			s.mu.Unlock()
			writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
			return
		}
		s.mu.Unlock()
		// Mount + env mirror what recreateArkWithClusterMount does.
		mountOverrides = map[string]string{
			arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + req.ARKClusterID,
		}
		if req.ConfigValues == nil {
			req.ConfigValues = map[string]string{}
		}
		req.ConfigValues["CLUSTER_ID"] = req.ARKClusterID
		// Don't set -ClusterDirOverride. acekorneya's launch_ASA.sh expands
		// $CUSTOM_SERVER_ARGS unquoted; any path with spaces gets word-split
		// into multiple argv elements, and the wine-virtualized install path
		// always has spaces — see panel/memory/changelog_2026-04-28.md. The
		// bind mount targets ARK's default cluster path, so the override is
		// redundant and the default works.
	}

	// 7DTD cluster join at create time — the twin of the ARK block above.
	// Lets the operator pick an existing 7DTD cluster from the create
	// form's dropdown so the very first container is born with the shared
	// /cluster bind mount + CLUSTER_ID env, instead of the post-create Join
	// flow which has to stop, delete, and recreate the container to swap in
	// the mount. module_id is either ark-sa or 7dtd, so at most one of these
	// two blocks ever runs and sets mountOverrides.
	if req.SevenDtdClusterID != "" && req.ModuleID == "7dtd" && h.sevenDtdClusters != nil {
		s := h.sevenDtdClusters
		s.mu.Lock()
		reg, lerr := s.load()
		if lerr != nil {
			s.mu.Unlock()
			writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
			return
		}
		// Validate the cluster_id maps to a real registered cluster, and grab
		// its recorded master for an optional mod-seed.
		var found bool
		var seedFrom string
		for _, c := range reg.Clusters {
			if c.ID == req.SevenDtdClusterID {
				found = true
				seedFrom = c.Master
				break
			}
		}
		if !found {
			s.mu.Unlock()
			writeError(w, http.StatusBadRequest, "unknown_cluster", fmt.Sprintf("7dtd cluster %q not found", req.SevenDtdClusterID))
			return
		}
		// VERSION GUARD (create-time cluster join — mirrors handle7dtdClusterJoin).
		// Refuse creating a server INTO a cluster whose existing members run a
		// different game build; cross-version members morph the shared player
		// save (CLUSTER_PLAYBOOK §2). The new server's branch must match them.
		joinerBranch := sevenDtdBranchKey(req.ConfigValues["provider_id"])
		for inst, cid := range reg.Members {
			if cid != req.SevenDtdClusterID || inst == req.InstanceID {
				continue
			}
			mrow, mErr := h.db.GetInstanceRow(r.Context(), inst)
			if mErr != nil {
				continue // transient read miss on one member — don't hard-fail
			}
			memberBranch := sevenDtdBranchKey(mrow.ConfigValues["provider_id"])
			if memberBranch != joinerBranch {
				s.mu.Unlock()
				writeError(w, http.StatusConflict, "cluster_version_mismatch",
					fmt.Sprintf("cannot create this server into cluster %q: its members run game version %q but this server would be %q. Every cluster member must run the identical build — shared player saves morph/corrupt across versions. Create it on %q instead.",
						req.SevenDtdClusterID, memberBranch, joinerBranch, memberBranch))
				return
			}
		}
		reg.Members[req.InstanceID] = req.SevenDtdClusterID
		if err := s.save(reg); err != nil {
			s.mu.Unlock()
			writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
			return
		}
		// Resolve the mod-seed source while we still hold the lock: the recorded
		// master, else (pre-master clusters) any other existing member. Never
		// seed from the joining instance itself.
		if req.SevenDtdSeedMods && (seedFrom == "" || seedFrom == req.InstanceID) {
			seedFrom = ""
			for inst, cid := range reg.Members {
				if cid == req.SevenDtdClusterID && inst != req.InstanceID {
					seedFrom = inst
					break
				}
			}
		}
		s.mu.Unlock()
		// Mount + env mirror what recreate7dtdWithClusterMount does — the
		// container is born with the shared /cluster bind so its entrypoint
		// seeds/symlinks Player/ into the cluster's shared save on first boot.
		mountOverrides = map[string]string{
			sevenDtdClusterDirContainerPath: "$PANEL_DATA_ROOT/7dtdcluster/" + req.SevenDtdClusterID,
		}
		if req.ConfigValues == nil {
			req.ConfigValues = map[string]string{}
		}
		req.ConfigValues["CLUSTER_ID"] = req.SevenDtdClusterID
		// Mod-seed at create: the agent honors _PANEL_SEED_MODS_FROM in its
		// onUpdateComplete hook (after the base install / warm-seed, before the
		// operator's manual start), cloning the master's full Mods set so the
		// new cluster member plays identically.
		if req.SevenDtdSeedMods && seedFrom != "" {
			req.ConfigValues["_PANEL_SEED_MODS_FROM"] = seedFrom
			h.log.Info("create: 7dtd cluster join will seed mods from master", "instance_id", req.InstanceID, "src", seedFrom, "cluster", req.SevenDtdClusterID)
		}
	}

	if err := h.db.UpsertInstance(r.Context(), db.InstanceRow{
		InstanceID:    req.InstanceID,
		AgentID:       req.AgentID,
		ModuleID:      req.ModuleID,
		ModuleVersion: req.Version,
		RunMode:       "docker",
		Status:        "creating",
		ConfigValues:  req.ConfigValues,
		AssignedPorts: assignedMap,
	}); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}

	corrID := newCorrelationID("create")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceCreate{
			InstanceCreate: &panelv1.InstanceCreate{
				InstanceId:     req.InstanceID,
				ModuleId:       req.ModuleID,
				Version:        req.Version,
				RunMode:        panelv1.RunMode_RUN_MODE_DOCKER,
				Ports:          allocatedPorts,
				ConfigValues:   req.ConfigValues,
				MountOverrides: mountOverrides,
			},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	// Auto-forward public ports through opnfwd. Async + best-effort —
	// failures don't unwind the create. No-op when the integration is
	// disabled in panel_settings.
	if h.opnfwd != nil {
		go h.opnfwd.onInstanceCreated(context.Background(), req.InstanceID, req.AgentID, req.ModuleID, assignedMap)
	}
	writeJSON(w, http.StatusAccepted, map[string]string{
		"instance_id":    req.InstanceID,
		"correlation_id": corrID,
	})
}

// ---- POST /api/instances/{id}/ports ----
//
// Change an instance's exposed host-port mappings. Docker doesn't let you
// alter port bindings on an existing container, so the flow is:
//
//   1. Stop if running (graceful)
//   2. Delete without purge (preserves volumes → world/save data intact)
//   3. Recreate with new port overrides
//   4. Start if it was running before
//
// The whole dance runs async against the agent (each leg is a separate RPC
// with its own correlation id). We await each step on the pending registry
// so the HTTP call returns only when the instance is back to its previous
// lifecycle state.

type changePortsReq struct {
	// Full port entries (name, proto, container_port, host_port, internal).
	// The UI knows these from the module manifest; we don't duplicate the
	// module registry on the controller side.
	Ports []changePortsEntry `json:"ports"`
}
type changePortsEntry struct {
	Name          string `json:"name"`
	Proto         string `json:"proto"`
	ContainerPort uint32 `json:"container_port"`
	HostPort      uint32 `json:"host_port"`
	Internal      bool   `json:"internal"`
}

func (h *httpServer) changePorts(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id in path required")
		return
	}
	var req changePortsReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if len(req.Ports) == 0 {
		writeError(w, http.StatusBadRequest, "no_ports", "ports is required")
		return
	}
	for _, p := range req.Ports {
		if p.Name == "" {
			writeError(w, http.StatusBadRequest, "invalid_port", "port entry missing name")
			return
		}
		if p.HostPort < 1 || p.HostPort > 65535 {
			writeError(w, http.StatusBadRequest, "invalid_port", fmt.Sprintf("port %q host_port=%d out of range", p.Name, p.HostPort))
			return
		}
	}

	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var row *db.InstanceRow
	for i := range rows {
		if rows[i].InstanceID == id {
			row = &rows[i]
			break
		}
	}
	if row == nil {
		writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("instance %q not found", id))
		return
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		writeError(w, http.StatusServiceUnavailable, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
		return
	}

	// Translate into the wire PortMap set the agent expects.
	var ports []*panelv1.PortMap
	for _, p := range req.Ports {
		ports = append(ports, &panelv1.PortMap{
			Name:          p.Name,
			Proto:         p.Proto,
			ContainerPort: p.ContainerPort,
			HostPort:      p.HostPort,
			Internal:      p.Internal,
		})
	}

	wasRunning := row.Status == "running"

	ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
	defer cancel()

	// 1. Stop (tolerate "already stopped").
	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: 30},
		}); err != nil {
			writeError(w, http.StatusBadGateway, "stop_failed", err.Error())
			return
		}
	}
	// 2. Delete without purging volumes — preserves world data.
	if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: id, PurgeVolumes: false},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "delete_failed", err.Error())
		return
	}
	// 3. Recreate with new ports. This is the first recreate so we must
	//    also re-upsert the DB row (status=creating) otherwise the row is
	//    the stale one referring to the already-deleted container.
	//    Also persist the user's chosen host ports into assigned_ports
	//    so future allocations on this agent treat them as reserved.
	chosenAssigned := map[string]uint32{}
	for _, p := range req.Ports {
		chosenAssigned[p.Name] = p.HostPort
	}
	if err := h.db.UpsertInstance(ctx, db.InstanceRow{
		InstanceID: id, AgentID: row.AgentID, ModuleID: row.ModuleID,
		ModuleVersion: row.ModuleVersion, RunMode: row.RunMode, Status: "creating",
		ConfigValues:  row.ConfigValues,
		AssignedPorts: chosenAssigned,
	}); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	// Suppress agent auto-start if the instance was stopped before this
	// port-change. The operator changed ports on a stopped server; they
	// shouldn't see it auto-boot.
	createCV := row.ConfigValues
	if !wasRunning {
		createCV = make(map[string]string, len(row.ConfigValues)+1)
		for k, v := range row.ConfigValues {
			createCV[k] = v
		}
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: &panelv1.InstanceCreate{
			InstanceId:     id,
			ModuleId:       row.ModuleID,
			Version:        row.ModuleVersion,
			RunMode:        panelv1.RunMode_RUN_MODE_DOCKER,
			Ports:          ports,
			ConfigValues:   createCV,
			MountOverrides: h.mountOverridesFor(id),
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "create_failed", err.Error())
		return
	}
	// 4. Restart if it was running before.
	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: id},
		}); err != nil {
			writeError(w, http.StatusBadGateway, "start_failed", err.Error())
			return
		}
	}
	out := map[string]uint32{}
	for _, p := range req.Ports {
		out[p.Name] = p.HostPort
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"instance_id": id,
		"ports":       out,
		"restarted":   wasRunning,
	})
}

// ---- POST /api/instances/{id}/env-config ----
//
// Operator-initiated change to env-var-driven config that requires a
// container recreate to take effect. Same stop→delete-preserve→create
// dance as changePorts and the cluster join, just for env vars.
//
// Used today by the ARK SA "change map" dropdown — SERVER_MAP is an
// env var the acekorneya entrypoint reads to assemble the launch
// command. Generic enough that other modules can hook the same endpoint
// without controller changes (just send the keys you want to update).
//
// Body shape: {"updates": {"SERVER_MAP": "TheCenter_WP"}}.
// Anything in `updates` is merged into the row's config_values
// (existing keys overwritten, new keys added). Caller can pass
// {"SOMETHING": ""} to clear a key.

type envConfigReq struct {
	Updates map[string]string `json:"updates"`
}

func (h *httpServer) recreateWithEnvConfig(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required")
		return
	}
	var req envConfigReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		// /rebuild calls with no body are legal — they're a pure recreate
		// against the existing config_values. Only fail bad_json when the
		// caller actually sent bytes.
		if err != io.EOF {
			writeError(w, http.StatusBadRequest, "bad_json", err.Error())
			return
		}
	}
	// Empty updates are legal: the Rebuild button uses this path to recreate
	// the container against the latest module image without changing any
	// config values. The merge loop below is a no-op in that case and the
	// existing config_values + assigned_ports + mount_overrides survive intact.
	// Audit log: capture actor + the keys/values being changed before we
	// stop+delete+recreate. When something destructive happens (e.g.
	// _PANEL_WIPE_PATHS=SavedArks) we want to know who clicked the button
	// — the controller has no other actor-attribution layer.
	actorEmail, actorID := "anonymous", "0"
	if u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow); ok && u != nil {
		actorEmail = u.Email
		actorID = fmt.Sprintf("%d", u.ID)
	}
	auditKeys := make([]string, 0, len(req.Updates))
	for k, v := range req.Updates {
		// Don't log raw values for unknown keys (could be passwords) —
		// but DO log the value for sentinels and known-safe map keys so
		// destructive intent is visible.
		safe := k == "_PANEL_WIPE_PATHS" || k == "MAP_NAME" || k == "SERVER_MAP" || k == "SESSION_NAME"
		if safe {
			auditKeys = append(auditKeys, fmt.Sprintf("%s=%q", k, v))
		} else if v == "" {
			auditKeys = append(auditKeys, k+"=<cleared>")
		} else {
			auditKeys = append(auditKeys, k+"=<set>")
		}
	}
	h.log.Info("env-config recreate requested",
		"instance_id", id,
		"actor_email", actorEmail,
		"actor_id", actorID,
		"remote_addr", r.RemoteAddr,
		"updates", auditKeys)

	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var row *db.InstanceRow
	for i := range rows {
		if rows[i].InstanceID == id {
			row = &rows[i]
			break
		}
	}
	if row == nil {
		writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("instance %q not found", id))
		return
	}
	// Refuse an env-config recreate while a SteamCMD install/update is in flight.
	// The recreate is a stop→delete-preserve→create dance, and the delete cancels
	// the in-flight updater's sidecar mid-download ("wait sidecar: context
	// canceled"), leaving the game volume empty and the instance crashed. A real
	// gamehost customer hit exactly this: they saved settings on a Palworld/Soulmask
	// server ~90s after checkout, while its first-time download was still running,
	// and the recreate nuked the install. The download must win; the caller retries
	// once the server is ready. (See changelog 2026-07-10.)
	if row.Status == "updating" {
		writeError(w, http.StatusConflict, "install_in_progress",
			"server is still installing/updating — settings can't be applied yet; try again once it's ready")
		return
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		writeError(w, http.StatusServiceUnavailable, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
		return
	}

	// Merge new values onto existing config_values. "" clears.
	merged := map[string]string{}
	for k, v := range row.ConfigValues {
		merged[k] = v
	}
	for k, v := range req.Updates {
		if v == "" {
			delete(merged, k)
		} else {
			merged[k] = v
		}
	}
	// One-shot sentinels: send to the agent for THIS recreate, but never
	// persist to the DB. If they leak into config_values, every future
	// recreate re-fires them — that's how a single "wipe world data"
	// click became a permanent SavedArks-deleting time bomb.
	persisted := map[string]string{}
	for k, v := range merged {
		if k == "_PANEL_WIPE_PATHS" || k == "_PANEL_SUPPRESS_AUTOSTART" {
			continue
		}
		persisted[k] = v
	}
	wasRunning := row.Status == "running"
	ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
	defer cancel()

	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: 30},
		}); err != nil {
			writeError(w, http.StatusBadGateway, "stop_failed", err.Error())
			return
		}
	}
	if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: id, PurgeVolumes: false},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "delete_failed", err.Error())
		return
	}
	if err := h.db.UpsertInstance(ctx, db.InstanceRow{
		InstanceID:    id,
		AgentID:       row.AgentID,
		ModuleID:      row.ModuleID,
		ModuleVersion: row.ModuleVersion,
		RunMode:       row.RunMode,
		Status:        "creating",
		ConfigValues:  persisted,
		AssignedPorts: row.AssignedPorts, // preserve port assignments
	}); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	// Pass the previously-allocated host ports to the agent so its
	// auto-bumper uses them as the starting point instead of bumping
	// from the manifest defaults (which produces collisions when
	// multiple sibling instances share the same default port set —
	// e.g. three ARK servers all racing for 7777).
	var ports []*panelv1.PortMap
	for name, hp := range row.AssignedPorts {
		ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
	}
	// Suppress agent's post-create auto-start when the prior state was
	// stopped — operator who edits config on a stopped server shouldn't
	// have it boot just because they applied a knob change. We send the
	// start ourselves below only when wasRunning.
	// Copy so we can inject control sentinels without mutating `merged`
	// (echoed back in the response). This recreate PRESERVES the install
	// volume, so tell the agent to skip the create-time warm-seed +
	// first-update — otherwise it wipes /game and re-seeds from a sibling,
	// silently dropping a 7DTD server's custom Mods on every knob change.
	createCV := make(map[string]string, len(merged)+2)
	for k, v := range merged {
		createCV[k] = v
	}
	createCV["_PANEL_SKIP_REINSTALL"] = "true"
	if !wasRunning {
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: &panelv1.InstanceCreate{
			InstanceId:     id,
			ModuleId:       row.ModuleID,
			Version:        row.ModuleVersion,
			RunMode:        panelv1.RunMode_RUN_MODE_DOCKER,
			Ports:          ports,
			ConfigValues:   createCV,
			MountOverrides: h.mountOverridesFor(id),
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "create_failed", err.Error())
		return
	}
	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: id},
		}); err != nil {
			writeError(w, http.StatusBadGateway, "start_failed", err.Error())
			return
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"instance_id":   id,
		"config_values": merged,
		"restarted":     wasRunning,
	})
}

// awaitAgentCall sends an envelope to an agent and blocks on the pending
// registry for the matching CommandResult.
func (h *httpServer) awaitAgentCall(ctx context.Context, conn *agentConn, tag string, payload any) error {
	corrID := newCorrelationID(tag)
	env := &panelv1.ControllerEnvelope{CorrelationId: corrID, SentAt: timestamppb.Now()}
	switch p := payload.(type) {
	case *panelv1.ControllerEnvelope_InstanceCreate:
		env.Payload = p
	case *panelv1.ControllerEnvelope_InstanceStart:
		env.Payload = p
	case *panelv1.ControllerEnvelope_InstanceStop:
		env.Payload = p
	case *panelv1.ControllerEnvelope_InstanceDelete:
		env.Payload = p
	default:
		return fmt.Errorf("unhandled payload kind %T", payload)
	}
	if err := conn.Send(env); err != nil {
		return fmt.Errorf("send %s: %w", tag, err)
	}
	// 90s should cover a SteamCMD-free stop/create/start. Anything longer
	// and the operator probably wants to know something is wrong.
	reply, err := h.pending.Await(ctx, corrID, 90*time.Second)
	if err != nil {
		return fmt.Errorf("%s await: %w", tag, err)
	}
	if r := reply.GetResult(); r != nil && r.Error != nil && r.Error.Message != "" {
		return fmt.Errorf("%s: %s", tag, r.Error.Message)
	}
	if r := reply.GetResult(); r != nil && r.ExitCode != 0 && (r.Error == nil || r.Error.Message == "") {
		return fmt.Errorf("%s: exit=%d", tag, r.ExitCode)
	}
	return nil
}

// ---- GET /api/instances/{id}/logs/recent ----
//
// Returns the ring-buffer tail so the Console tab can show context
// immediately on open instead of waiting for new events. Mirrors the SSE
// event shape exactly so the UI can render both with the same function.

func (h *httpServer) recentLogs(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required in path")
		return
	}
	limit := 400
	if v := r.URL.Query().Get("limit"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 2000 {
			limit = n
		}
	}
	if h.logBuf == nil {
		writeJSON(w, http.StatusOK, map[string]any{"events": []any{}})
		return
	}
	events := h.logBuf.Recent(id, limit)
	marshaler := protojson.MarshalOptions{EmitUnpopulated: true, UseProtoNames: true}
	out := make([]json.RawMessage, 0, len(events))
	for _, ev := range events {
		body, err := marshaler.Marshal(ev)
		if err != nil {
			continue
		}
		out = append(out, body)
	}
	writeJSON(w, http.StatusOK, map[string]any{"events": out})
}

// ---- POST /api/instances/{id}/start ----

func (h *httpServer) startInstance(w http.ResponseWriter, r *http.Request) {
	h.instanceAction(w, r, "start")
}

// ---- POST /api/instances/{id}/stop ----

func (h *httpServer) stopInstance(w http.ResponseWriter, r *http.Request) {
	h.instanceAction(w, r, "stop")
}

// ---- POST /api/instances/{id}/restart ----
//
// Server-side restart: stop → await the agent's CommandResult (which the
// agent only sends AFTER the container has actually exited) → dispatch
// start. Replaces the old client-side stop+poll+start chain, which raced:
// the browser's poll could fire Start while the graceful stop was still
// draining, the start "succeeded" against the still-running container, and
// the stop then killed the freshly-started server. Sequencing here also
// survives a page refresh mid-restart (the old client chain didn't — the
// stop landed and the start never fired).
//
// Returns after the start is DISPATCHED (202 + correlation id), not after
// the game boots — boot progress flows over SSE as usual. Total handler
// time ≈ the graceful-stop duration (seconds to ~grace), safely under the
// proxy chain's read timeout.
func (h *httpServer) restartInstance(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required in path")
		return
	}
	row, err := h.db.GetInstanceRow(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", row.AgentID))
		return
	}
	grace := uint32(30)
	if gs := r.URL.Query().Get("grace"); gs != "" {
		if v, perr := strconv.ParseUint(gs, 10, 32); perr == nil {
			grace = uint32(v)
		}
	}
	// Already down → skip the stop leg instead of waiting on a no-op.
	if row.Status != "stopped" && row.Status != "crashed" {
		ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
		defer cancel()
		if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: grace},
		}); err != nil {
			writeError(w, http.StatusBadGateway, "stop_failed", err.Error())
			return
		}
	}
	corrID := newCorrelationID("start")
	env := &panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: id},
		},
	}
	if err := conn.Send(env); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"correlation_id": corrID})
}

// instanceAction looks up the instance's agent in the DB and forwards
// start/stop over the agent's gRPC stream.
func (h *httpServer) instanceAction(w http.ResponseWriter, r *http.Request, action string) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required in path")
		return
	}
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var row *db.InstanceRow
	for i := range rows {
		if rows[i].InstanceID == id {
			row = &rows[i]
			break
		}
	}
	if row == nil {
		writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("instance %q not in db", id))
		return
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", row.AgentID))
		return
	}
	corrID := newCorrelationID(action)
	var env *panelv1.ControllerEnvelope
	switch action {
	case "start":
		env = &panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_InstanceStart{
				InstanceStart: &panelv1.InstanceStart{InstanceId: id},
			},
		}
	case "stop":
		grace := uint32(30)
		if gs := r.URL.Query().Get("grace"); gs != "" {
			if v, err := strconv.ParseUint(gs, 10, 32); err == nil {
				grace = uint32(v)
			}
		}
		env = &panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_InstanceStop{
				InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: grace},
			},
		}
	default:
		writeError(w, http.StatusBadRequest, "unknown_action", action)
		return
	}
	if err := conn.Send(env); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]string{"correlation_id": corrID})
}

// ---- Backups ----

type backupDTO struct {
	ID          string `json:"id"`
	InstanceID  string `json:"instance_id"`
	AgentID     string `json:"agent_id"`
	Path        string `json:"path"`
	SizeBytes   int64  `json:"size_bytes"`
	Description string `json:"description,omitempty"`
	CreatedAt   string `json:"created_at"`
}

func (h *httpServer) listBackups(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	rows, err := h.db.ListBackupsByInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	out := make([]backupDTO, 0, len(rows))
	for _, b := range rows {
		out = append(out, backupDTO{
			ID:          b.ID,
			InstanceID:  b.InstanceID,
			AgentID:     b.AgentID,
			Path:        b.Path,
			SizeBytes:   b.SizeBytes,
			Description: b.Description,
			CreatedAt:   b.CreatedAt.Format(time.RFC3339),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"backups": out})
}

type createBackupReq struct {
	Description string `json:"description,omitempty"`
}

func (h *httpServer) createBackup(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	var req createBackupReq
	if r.Body != nil {
		_ = json.NewDecoder(r.Body).Decode(&req)
	}
	res, err := h.runBackup(r.Context(), agentID, id, req.Description)
	if err != nil {
		// Map known failures to clearer status codes.
		msg := err.Error()
		switch {
		case strings.Contains(msg, "agent_offline"):
			writeError(w, http.StatusNotFound, "agent_offline", msg)
		case strings.Contains(msg, "timeout"):
			writeError(w, http.StatusGatewayTimeout, "timeout", msg)
		default:
			writeError(w, http.StatusInternalServerError, "backup_failed", msg)
		}
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{
		"id":         res.BackupId,
		"path":       res.Path,
		"size_bytes": res.SizeBytes,
	})
}

// runBackup is a thin wrapper around runBackupShared for HTTP callers.
func (h *httpServer) runBackup(ctx context.Context, agentID, instanceID, description string) (*panelv1.BackupResult, error) {
	return runBackupShared(ctx, h.registry, h.pending, h.db, agentID, instanceID, description)
}

// runBackupShared is the shared agent-side pipeline for both the HTTP
// /backups POST handler and the scheduler's "backup" action. Sends a
// BackupRequest to the agent, waits for the BackupResult, and inserts
// the DB row on success.
//
// Pulled out as a free function so main.go can wire it into the
// scheduler Executor before the httpServer struct is constructed.
func runBackupShared(ctx context.Context, registry *agentRegistry, pending *pendingRegistry, database *db.DB, agentID, instanceID, description string) (*panelv1.BackupResult, error) {
	conn := registry.get(agentID)
	if conn == nil {
		return nil, fmt.Errorf("agent_offline: agent %q not connected", agentID)
	}
	corrID := newCorrelationID("backup")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Backup{
			Backup: &panelv1.BackupRequest{InstanceId: instanceID, Description: description},
		},
	}); err != nil {
		return nil, fmt.Errorf("forward: %w", err)
	}
	env, err := pending.Await(ctx, corrID, 30*time.Minute)
	if err != nil {
		return nil, fmt.Errorf("timeout: %w", err)
	}
	res := env.GetBackupResult()
	if res == nil {
		return nil, fmt.Errorf("agent reply missing backup_result")
	}
	if res.Error != "" {
		return nil, fmt.Errorf("%s", res.Error)
	}
	if err := database.InsertBackup(ctx, db.BackupRow{
		ID:          res.BackupId,
		InstanceID:  instanceID,
		AgentID:     agentID,
		Path:        res.Path,
		SizeBytes:   res.SizeBytes,
		Description: description,
	}); err != nil {
		return nil, fmt.Errorf("db: %w", err)
	}
	return res, nil
}

func (h *httpServer) restoreBackup(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	bkpID := r.PathValue("bkpId")
	b, err := h.db.GetBackup(r.Context(), bkpID)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if b.InstanceID != id {
		writeError(w, http.StatusBadRequest, "mismatch", "backup belongs to a different instance")
		return
	}
	conn := h.registry.get(b.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", b.AgentID))
		return
	}
	corrID := newCorrelationID("restore")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Restore{
			Restore: &panelv1.RestoreRequest{InstanceId: id, BackupId: bkpID, BackupPath: b.Path},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 30*time.Minute)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetRestoreResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing restore_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusInternalServerError, "restore_failed", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"bytes_restored": res.BytesRestored})
}

// listBackupEntries returns every path inside a backup tarball without
// extracting it. The agent reads its own host filesystem (no sidecar);
// .tar.gz entries are streamed through Go's archive/tar so this is
// fast even for multi-GB backups.
//
// Response shape:
//
//	{
//	  "entries": [
//	    {"path": "Saves/v4/panel/...", "size": 7053658, "is_dir": false,
//	     "mode": "-rw-r--r--", "mod_time": "2026-04-29T08:05:28Z"},
//	    ...
//	  ],
//	  "total_bytes": 19283746
//	}
func (h *httpServer) listBackupEntries(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	bkpID := r.PathValue("bkpId")
	h.log.Info("backup browse: list entries", "instance_id", id, "backup_id", bkpID)
	b, err := h.db.GetBackup(r.Context(), bkpID)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if b.InstanceID != id {
		writeError(w, http.StatusBadRequest, "mismatch", "backup belongs to a different instance")
		return
	}
	conn := h.registry.get(b.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", b.AgentID))
		return
	}
	corrID := newCorrelationID("bkp_list")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_BackupListEntries{
			BackupListEntries: &panelv1.BackupListEntriesRequest{
				InstanceId: id,
				BackupId:   bkpID,
				BackupPath: b.Path,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 60*time.Second)
	if err != nil {
		h.log.Warn("backup browse: list entries await failed", "instance_id", id, "backup_id", bkpID, "err", err)
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetBackupListEntriesResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing backup_list_entries_result")
		return
	}
	if res.Error != "" {
		h.log.Warn("backup browse: agent reported error", "instance_id", id, "backup_id", bkpID, "err", res.Error)
		writeError(w, http.StatusInternalServerError, "list_failed", res.Error)
		return
	}
	h.log.Info("backup browse: list entries ok", "instance_id", id, "backup_id", bkpID, "count", len(res.Entries), "total_bytes", res.TotalBytes)
	out := make([]map[string]any, 0, len(res.Entries))
	for _, e := range res.Entries {
		out = append(out, map[string]any{
			"path":     e.Path,
			"size":     e.Size,
			"is_dir":   e.IsDir,
			"mode":     e.Mode,
			"mod_time": e.ModTime.AsTime().UTC().Format(time.RFC3339),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"entries":     out,
		"total_bytes": res.TotalBytes,
	})
}

// readBackupFile streams a single entry's contents out of a backup
// tarball. ?path=<entry-path> selects which file. ?max_bytes=<n> caps
// the read (default 8 MiB, hard ceiling 64 MiB on the agent side).
//
// For text entries the response is JSON wrapping the content; for
// binaries (NUL byte detected in first 8 KiB) the response includes
// is_binary:true so the UI can offer a download instead of a viewer.
//
//	{
//	  "path": "Saves/.../foo.json",
//	  "content": "...",        // base64-decoded plain text or base64 if binary
//	  "size": 12345,            // size from tar header (may be > content length if truncated)
//	  "truncated": false,
//	  "is_binary": false
//	}
func (h *httpServer) readBackupFile(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	bkpID := r.PathValue("bkpId")
	entryPath := r.URL.Query().Get("path")
	if entryPath == "" {
		writeError(w, http.StatusBadRequest, "bad_query", "path query param required")
		return
	}
	maxBytes := int64(0)
	if mb := r.URL.Query().Get("max_bytes"); mb != "" {
		if v, err := strconv.ParseInt(mb, 10, 64); err == nil && v > 0 {
			maxBytes = v
		}
	}
	b, err := h.db.GetBackup(r.Context(), bkpID)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if b.InstanceID != id {
		writeError(w, http.StatusBadRequest, "mismatch", "backup belongs to a different instance")
		return
	}
	conn := h.registry.get(b.AgentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", b.AgentID))
		return
	}
	corrID := newCorrelationID("bkp_read")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_BackupReadFile{
			BackupReadFile: &panelv1.BackupReadFileRequest{
				InstanceId: id,
				BackupId:   bkpID,
				BackupPath: b.Path,
				EntryPath:  entryPath,
				MaxBytes:   maxBytes,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 90*time.Second)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetBackupReadFileResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing backup_read_file_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusInternalServerError, "read_failed", res.Error)
		return
	}
	// Always base64 the content — keeps the JSON envelope safe for binary
	// payloads and avoids surprising a curl user; the UI base64-decodes
	// once it knows whether to render-as-text or hand off to a download.
	writeJSON(w, http.StatusOK, map[string]any{
		"path":      entryPath,
		"content":   base64.StdEncoding.EncodeToString(res.Content),
		"size":      res.Size,
		"truncated": res.Truncated,
		"is_binary": res.IsBinary,
	})
}

func (h *httpServer) deleteBackup(w http.ResponseWriter, r *http.Request) {
	bkpID := r.PathValue("bkpId")
	b, err := h.db.GetBackup(r.Context(), bkpID)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	// Best-effort file removal via the agent: no proto path for this yet,
	// so we just delete the DB row. The tarball on disk survives — an
	// operator can rm it manually, or we add a FsDelete envelope later.
	if err := h.db.DeleteBackup(r.Context(), b.ID); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"id": b.ID, "note": "DB row removed; tarball on agent still on disk — delete manually"})
}

// ---- DELETE /api/instances/{id} ----

// deleteInstance tears down the instance entirely. Optional query params:
//
//	?purge=true           also docker volume rm panel.instance_id-labeled volumes
//	?grace=N              grace seconds if the container is still running
func (h *httpServer) deleteInstance(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	purge := r.URL.Query().Get("purge") == "true"
	grace := uint32(15)
	if gs := r.URL.Query().Get("grace"); gs != "" {
		if v, err := strconv.ParseUint(gs, 10, 32); err == nil {
			grace = uint32(v)
		}
	}
	force := r.URL.Query().Get("force") == "true"

	// Eager cluster scrub. The state-event handler also scrubs on the
	// agent's "deleted" reply, but doing it here too is defense in depth:
	// if the agent is slow / disconnected / never replies, the registry
	// still gets cleaned and a re-create with the same id starts fresh.
	if h.arkClusters != nil {
		if cid, err := h.arkClusters.removeInstanceFromCluster(id); err != nil {
			h.log.Warn("delete: ark-cluster scrub failed", "instance_id", id, "err", err)
		} else if cid != "" {
			h.log.Info("delete: scrubbed ark-cluster membership", "instance_id", id, "cluster_id", cid)
		}
	}
	if h.sevenDtdClusters != nil {
		if cid, err := h.sevenDtdClusters.removeInstanceFromCluster(id); err != nil {
			h.log.Warn("delete: 7dtd-cluster scrub failed", "instance_id", id, "err", err)
		} else if cid != "" {
			h.log.Info("delete: scrubbed 7dtd-cluster membership", "instance_id", id, "cluster_id", cid)
		}
	}
	// Eager opnfwd scrub — same defense-in-depth: drop forwards before the
	// agent's container delete fires, so the OPNsense state mirrors the
	// operator's intent immediately. No-op when integration is disabled.
	if h.opnfwd != nil {
		h.opnfwd.onInstanceDeleting(r.Context(), id)
	}

	conn := h.registry.get(agentID)
	if conn == nil {
		// Agent offline path. Without ?force=true this is the historical
		// 404; with force, scrub the DB row directly so orphaned
		// instances (cards rendered in the "orphaned" pane on the
		// dashboard) can be cleaned up. Note: this leaves any actual
		// docker container/volumes dangling on the host — operator's
		// expectation is that the agent host is gone or will need
		// `docker rm` separately.
		if !force {
			writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected — pass ?force=true to delete the orphaned DB row", agentID))
			return
		}
		if err := h.db.DeleteInstance(r.Context(), id); err != nil {
			writeError(w, http.StatusInternalServerError, "db_delete", err.Error())
			return
		}
		h.log.Info("delete: forced (agent offline) — DB row + cluster scrubbed; container/volumes left on host", "instance_id", id, "agent_id", agentID)
		writeJSON(w, http.StatusOK, map[string]any{
			"accepted":    true,
			"instance_id": id,
			"forced":      true,
		})
		return
	}

	corrID := newCorrelationID("delete")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceDelete{
			InstanceDelete: &panelv1.InstanceDelete{InstanceId: id, GraceSeconds: grace, PurgeVolumes: purge},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{
		"accepted":       true,
		"instance_id":    id,
		"purge":          purge,
		"correlation_id": corrID,
	})
}

// ---- POST /api/instances/{id}/rcon ----

type rconReq struct {
	Command   string `json:"command"`
	TimeoutMs uint32 `json:"timeout_ms,omitempty"`
}

// execRCON looks up the agent owning the instance, forwards an RCON
// command over the agent stream, and waits synchronously for the reply.
func (h *httpServer) execRCON(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if id == "" {
		writeError(w, http.StatusBadRequest, "missing_id", "instance id required")
		return
	}
	var req rconReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.Command == "" {
		writeError(w, http.StatusBadRequest, "missing_command", "command is required")
		return
	}

	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var agentID string
	for _, row := range rows {
		if row.InstanceID == id {
			agentID = row.AgentID
			break
		}
	}
	if agentID == "" {
		writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("instance %q not in db", id))
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}

	timeout := 10 * time.Second
	if req.TimeoutMs > 0 {
		timeout = time.Duration(req.TimeoutMs) * time.Millisecond
	}

	corrID := newCorrelationID("rcon")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Rcon{
			Rcon: &panelv1.RCONCommand{InstanceId: id, Command: req.Command},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}

	env, err := h.pending.Await(r.Context(), corrID, timeout)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	result := env.GetRconResult()
	if result == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing rcon_result")
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{
		"output":         result.Output,
		"error":          result.Error,
		"correlation_id": corrID,
	})
}

// ---- Schedules CRUD ----

type scheduleDTO struct {
	ID                    string          `json:"id"`
	InstanceID            string          `json:"instance_id"`
	Description           string          `json:"description,omitempty"`
	TriggerKind           string          `json:"trigger_kind"`
	CronSpec              string          `json:"cron_spec,omitempty"`
	EventSpec             string          `json:"event_spec,omitempty"`
	EventSustainedSeconds int32           `json:"event_sustained_seconds,omitempty"`
	Action                json.RawMessage `json:"action"`
	Enabled               bool            `json:"enabled"`
	LastFiredAt           string          `json:"last_fired_at,omitempty"`
	LastStatus            string          `json:"last_status,omitempty"`
	LastDetail            string          `json:"last_detail,omitempty"`
	CreatedAt             string          `json:"created_at"`
	UpdatedAt             string          `json:"updated_at"`
}

func (h *httpServer) getSchedules(w http.ResponseWriter, r *http.Request) {
	rows, err := h.db.ListSchedules(r.Context(), r.URL.Query().Get("instance_id"))
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	out := make([]scheduleDTO, 0, len(rows))
	for _, row := range rows {
		dto := scheduleDTO{
			ID:                    row.ID,
			InstanceID:            row.InstanceID,
			Description:           row.Description,
			TriggerKind:           row.TriggerKind,
			CronSpec:              row.CronSpec,
			EventSpec:             row.EventSpec,
			EventSustainedSeconds: row.EventSustainedSeconds,
			Action:                row.Action,
			Enabled:               row.Enabled,
			LastStatus:            row.LastStatus,
			LastDetail:            row.LastDetail,
			CreatedAt:             row.CreatedAt.Format(time.RFC3339),
			UpdatedAt:             row.UpdatedAt.Format(time.RFC3339),
		}
		if row.LastFiredAt != nil {
			dto.LastFiredAt = row.LastFiredAt.Format(time.RFC3339)
		}
		out = append(out, dto)
	}
	writeJSON(w, http.StatusOK, map[string]any{"schedules": out})
}

type postScheduleReq struct {
	InstanceID            string          `json:"instance_id"`
	Description           string          `json:"description,omitempty"`
	TriggerKind           string          `json:"trigger_kind,omitempty"` // "cron" (default) or "event"
	CronSpec              string          `json:"cron_spec,omitempty"`
	EventSpec             string          `json:"event_spec,omitempty"`
	EventSustainedSeconds int32           `json:"event_sustained_seconds,omitempty"`
	Action                json.RawMessage `json:"action"`
	Enabled               *bool           `json:"enabled,omitempty"`
}

func (h *httpServer) postSchedule(w http.ResponseWriter, r *http.Request) {
	var req postScheduleReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.InstanceID == "" || len(req.Action) == 0 {
		writeError(w, http.StatusBadRequest, "missing_fields", "instance_id and action are required")
		return
	}
	kind := req.TriggerKind
	if kind == "" {
		kind = "cron"
	}
	switch kind {
	case "cron":
		if req.CronSpec == "" {
			writeError(w, http.StatusBadRequest, "missing_cron", "cron_spec required for cron trigger")
			return
		}
	case "event":
		if req.EventSpec == "" {
			writeError(w, http.StatusBadRequest, "missing_event", "event_spec (CEL expression) required for event trigger")
			return
		}
	default:
		writeError(w, http.StatusBadRequest, "bad_trigger", "trigger_kind must be 'cron' or 'event'")
		return
	}
	enabled := true
	if req.Enabled != nil {
		enabled = *req.Enabled
	}
	row := db.ScheduleRow{
		ID:                    newScheduleID(),
		InstanceID:            req.InstanceID,
		Description:           req.Description,
		TriggerKind:           kind,
		CronSpec:              req.CronSpec,
		EventSpec:             req.EventSpec,
		EventSustainedSeconds: req.EventSustainedSeconds,
		Action:                req.Action,
		Enabled:               enabled,
	}
	if err := h.engine.Add(row); err != nil {
		writeError(w, http.StatusBadRequest, "add_failed", err.Error())
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{"id": row.ID})
}

func (h *httpServer) deleteSchedule(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if err := h.engine.Remove(id); err != nil {
		writeError(w, http.StatusInternalServerError, "delete", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"id": id})
}

func (h *httpServer) enableSchedule(w http.ResponseWriter, r *http.Request)  { h.setSchedEnabled(w, r, true) }
func (h *httpServer) disableSchedule(w http.ResponseWriter, r *http.Request) { h.setSchedEnabled(w, r, false) }

func (h *httpServer) setSchedEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
	id := r.PathValue("id")
	if err := h.engine.SetEnabled(id, enabled); err != nil {
		writeError(w, http.StatusInternalServerError, "set_enabled", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"id": id, "enabled": enabled})
}

// patchSchedule updates mutable fields of an existing schedule.
// trigger_kind is structural and cannot be changed — the request is
// rejected with 400 if the body's trigger_kind differs from the existing
// value. Enabled is optional; when omitted the current value is preserved.
func (h *httpServer) patchSchedule(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")

	// Load existing row.
	rows, err := h.db.ListSchedules(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var existing *db.ScheduleRow
	for i := range rows {
		if rows[i].ID == id {
			existing = &rows[i]
			break
		}
	}
	if existing == nil {
		writeError(w, http.StatusNotFound, "not_found", fmt.Sprintf("schedule %q not found", id))
		return
	}

	// Parse partial body.
	var req postScheduleReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}

	// trigger_kind is structural — reject if different.
	if req.TriggerKind != "" && req.TriggerKind != existing.TriggerKind {
		writeError(w, http.StatusBadRequest, "immutable_trigger_kind",
			"trigger_kind cannot be changed; delete and re-create the schedule instead")
		return
	}

	// Build the merged row. Fields that came in the body override;
	// everything else stays as-is.
	row := *existing
	if req.Description != "" {
		row.Description = req.Description
	}
	if req.CronSpec != "" {
		row.CronSpec = req.CronSpec
	}
	if req.EventSpec != "" {
		row.EventSpec = req.EventSpec
	}
	if req.EventSustainedSeconds != 0 {
		row.EventSustainedSeconds = req.EventSustainedSeconds
	}
	if len(req.Action) > 0 {
		row.Action = req.Action
	}
	if req.Enabled != nil {
		row.Enabled = *req.Enabled
	}

	if err := h.engine.Update(row); err != nil {
		writeError(w, http.StatusInternalServerError, "update_failed", err.Error())
		return
	}

	// Re-read the canonical row from DB for the response.
	rows2, err := h.db.ListSchedules(r.Context(), "")
	if err == nil {
		for _, r2 := range rows2 {
			if r2.ID == id {
				dto := scheduleDTO{
					ID:                    r2.ID,
					InstanceID:            r2.InstanceID,
					Description:           r2.Description,
					TriggerKind:           r2.TriggerKind,
					CronSpec:              r2.CronSpec,
					EventSpec:             r2.EventSpec,
					EventSustainedSeconds: r2.EventSustainedSeconds,
					Action:                r2.Action,
					Enabled:               r2.Enabled,
					LastStatus:            r2.LastStatus,
					LastDetail:            r2.LastDetail,
					CreatedAt:             r2.CreatedAt.Format(time.RFC3339),
					UpdatedAt:             r2.UpdatedAt.Format(time.RFC3339),
				}
				if r2.LastFiredAt != nil {
					dto.LastFiredAt = r2.LastFiredAt.Format(time.RFC3339)
				}
				writeJSON(w, http.StatusOK, dto)
				return
			}
		}
	}
	// Fallback: return the merged row if re-read fails.
	writeJSON(w, http.StatusOK, map[string]any{"id": id, "status": "updated"})
}

// ---- Updates ----

// listUpdates returns the manifest-declared update providers for the
// instance's module. Used by the UI to populate an "update" dropdown.
func (h *httpServer) listUpdates(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	_ = agentID // not actually needed; providers are on the manifest which the controller can resolve from the DB row's module_id — but modules live on agents. For MVP we return only what the DB knows; full resolution is a future feature.
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	var moduleID string
	for _, row := range rows {
		if row.InstanceID == id {
			moduleID = row.ModuleID
			break
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"instance_id": id,
		"module_id":   moduleID,
		"note":        "provider enumeration will be added once controller maintains its own module registry mirror; for now, trigger with an empty provider_id to use the module's first provider",
	})
}

type updateReq struct {
	ProviderID string `json:"provider_id,omitempty"`
	// AllowVersionChange unlocks a deliberate game-version change on a 7DTD
	// instance pinned to a frozen branch. The UI sets this only after a
	// typed-slug confirm, because moving a live world between game versions
	// (e.g. 2.6 → 3.0) can corrupt the save. Ignored for non-7dtd modules.
	AllowVersionChange bool `json:"allow_version_change,omitempty"`
}

// sevenDtdProviderLocked reports whether a 7DTD update_provider id pins a
// FROZEN Steam branch (v2.6, v2.5, …) as opposed to a MOVING one. The moving
// branches (public/stable, latest_experimental) auto-advance — notably public
// flips to 3.0 on 2026-06-29 — so they are never "locked". A frozen branch is
// the version lock: pinned to a fixed buildid, immune to the public flip.
func sevenDtdProviderLocked(providerID string) bool {
	switch strings.ToLower(strings.TrimSpace(providerID)) {
	case "", "current", "stable", "latest_experimental":
		return false
	}
	return true
}

// sevenDtdBranchKey maps a 7DTD update_provider id to the underlying Steam
// BRANCH that determines the actual game binaries — the comparison key used to
// enforce cluster build-consistency (CLUSTER_PLAYBOOK §2: every member must run
// the byte-identical build or the shared player save morphs/corrupts across
// versions). Empty/unpinned resolves to the module default branch v2.6, which
// matches the agent's empty→UpdateProviders[0] behavior. Unknown/future ids
// (e.g. a later v3_0) key to themselves so they still compare equal to a peer
// on the same id and unequal to a different version.
func sevenDtdBranchKey(providerID string) string {
	switch strings.ToLower(strings.TrimSpace(providerID)) {
	case "", "v2_6":
		return "v2.6"
	case "v2_5":
		return "v2.5"
	case "v2_4":
		return "v2.4"
	case "v2_3":
		return "v2.3"
	case "v2_0":
		return "v2.0"
	case "v1_4":
		return "v1.4"
	case "current", "stable":
		return "public"
	case "latest_experimental":
		return "latest_experimental"
	}
	return strings.ToLower(strings.TrimSpace(providerID))
}

// updateInstance kicks off an async update on the agent. Progress arrives
// as LogLine events on the bus; the dashboard's events pane shows them.
// installBaseMods POSTs the agent's base-mods installer (Allocs + PrismaCore)
// onto an instance. 7DTD-only; instance must be stopped (game holds Mods
// DLLs open). Routes through the Update RPC with the magic provider id
// "_basemods" — agent dispatcher recognizes it and runs the install
// sidecar instead of the module's normal updater. Async — returns 202
// once the agent's accept ack lands.
func (h *httpServer) installBaseMods(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	row, err := h.db.GetInstanceRow(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if row.ModuleID != "7dtd" {
		writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("base-mods installer is 7DTD-only; instance %q is module %q", id, row.ModuleID))
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusBadGateway, "agent_offline", fmt.Sprintf("agent %q is not connected", agentID))
		return
	}
	corrID := newCorrelationID("update-basemods")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Update{
			Update: &panelv1.UpdateRequest{InstanceId: id, ProviderId: "_basemods"},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	h.log.Info("install-base-mods kicked", "instance", id, "agent", agentID, "corr_id", corrID)
	writeJSON(w, http.StatusAccepted, map[string]any{
		"ok":             true,
		"instance_id":    id,
		"correlation_id": corrID,
		"note":           "base-mods install kicked off async (~30-60s); pulls Allocs from illy.bz + PrismaCore latest from GitHub",
	})
}

func (h *httpServer) updateInstance(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req updateReq
	if r.Body != nil {
		_ = json.NewDecoder(r.Body).Decode(&req) // allow empty body
	}

	// ---- 7DTD version-lock gate -------------------------------------------
	// 7DTD ships frozen per-version Steam branches (v2.6, v2.5, …) alongside the
	// MOVING public branch — which becomes 3.0 on 2026-06-29 and would corrupt a
	// 2.6 world if pulled onto it. Each instance is pinned to the branch it was
	// created on (config_values.provider_id). Two protections, applied ONLY to
	// 7dtd so every other module keeps its existing update behavior:
	//   1. Empty provider on Update → re-pin to the instance's OWN branch (not
	//      manifest UpdateProviders[0]). Without this an experimental server
	//      would be silently downgraded to 2.6, and a 2.6 server bumped toward
	//      whatever [0] is. With it, "Update" means "re-validate my own branch".
	//   2. A request to CHANGE to a different branch on a LOCKED (frozen)
	//      instance is refused unless allow_version_change is set — the UI gates
	//      that behind a typed-slug confirm, because cross-version moves can
	//      break the save.
	effectiveProvider := req.ProviderID
	if req.ProviderID != "_basemods" { // never reinterpret the pseudo-provider
		if row, rerr := h.db.GetInstanceRow(r.Context(), id); rerr == nil && row.ModuleID == "7dtd" {
			pinned := ""
			if row.ConfigValues != nil {
				pinned = row.ConfigValues["provider_id"]
			}
			if effectiveProvider == "" {
				effectiveProvider = pinned // keep the server on its own branch
			}
			changing := effectiveProvider != "" && !strings.EqualFold(effectiveProvider, pinned)
			if changing && sevenDtdProviderLocked(pinned) && !req.AllowVersionChange {
				writeError(w, http.StatusConflict, "version_locked",
					fmt.Sprintf("instance %q is locked to %q; changing the game version can corrupt the world — confirm the unlock to proceed", id, pinned))
				return
			}
			// Confirmed (or unlocked) version change: persist the new branch so
			// future empty-provider updates follow it and the UI shows the new
			// lock state. Merge to avoid stomping other config_values.
			if changing {
				merged := map[string]string{}
				for k, v := range row.ConfigValues {
					merged[k] = v
				}
				merged["provider_id"] = effectiveProvider
				if uerr := h.db.UpdateInstanceConfigValues(r.Context(), id, merged); uerr != nil {
					h.log.Warn("7dtd version-change: failed to persist new provider_id", "instance", id, "err", uerr)
				}
			}
		}
	}

	// Check if the module's chosen provider requires Steam login (DayZ,
	// Arma, etc.). If it does and we don't have cached credentials, halt
	// the request with a 200 + {steam_login_required: true} so the UI can
	// pop the login modal. Once the operator logs in, the frontend will
	// POST here again and we'll inject the creds below.
	steamUser, steamPass := "", ""
	if needs, provider := h.providerNeedsSteamLogin(r.Context(), agentID, id, effectiveProvider); needs {
		u, p, ok := h.getSteamLoginForUpdate(r.Context())
		if !ok {
			writeJSON(w, http.StatusOK, map[string]any{
				"steam_login_required": true,
				"provider_id":          provider,
				"reason":               "this module requires a Steam account that owns the app; +login anonymous won't work",
			})
			return
		}
		steamUser, steamPass = u, p
	}
	corrID := newCorrelationID("update")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Update{
			Update: &panelv1.UpdateRequest{
				InstanceId:    id,
				ProviderId:    effectiveProvider,
				SteamUsername: steamUser,
				SteamPassword: steamPass,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 15*time.Second)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetUpdateResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing update_result")
		return
	}
	if !res.Accepted {
		writeError(w, http.StatusBadRequest, "update_rejected", res.Error)
		return
	}
	writeJSON(w, http.StatusAccepted, map[string]any{
		"accepted":       true,
		"provider_id":    res.ProviderId,
		"provider_kind":  res.ProviderKind,
		"correlation_id": corrID,
	})
}

// ---- GET /api/events (SSE) ----

// streamEvents subscribes to the event bus and pushes each event as an
// SSE "data:" line. Browsers can consume this with EventSource. Filters
// are optional query params: ?instance=X&agent=Y.
func (h *httpServer) streamEvents(w http.ResponseWriter, r *http.Request) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		writeError(w, http.StatusInternalServerError, "no_flush", "streaming unsupported")
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no")
	w.WriteHeader(http.StatusOK)
	// Emit an initial comment so the browser opens the stream promptly.
	_, _ = fmt.Fprintf(w, ": subscribed %s\n\n", time.Now().Format(time.RFC3339))
	flusher.Flush()

	filter := events.Filter{
		InstanceID:  r.URL.Query().Get("instance"),
		AgentID:     r.URL.Query().Get("agent"),
		ExcludeLogs: r.URL.Query().Get("exclude") == "log",
	}
	if h.prom != nil {
		h.prom.sseClients.Add(1)
		defer h.prom.sseClients.Add(-1)
	}
	// Subscribe (stamped: each live event carries its monotonic replay id)
	// BEFORE replaying, so nothing published in between is lost; the live
	// loop dedups the overlap by id.
	sub := h.bus.SubscribeStamped(filter)
	defer func() {
		dropped := sub.Dropped()
		sub.Close()
		if dropped > 0 {
			h.log.Warn("SSE subscriber dropped events — consumer too slow or burst too large",
				"dropped", dropped,
				"instance", r.URL.Query().Get("instance"),
				"agent", r.URL.Query().Get("agent"))
		}
	}()

	ctx, cancel := context.WithCancel(r.Context())
	defer cancel()
	keepalive := time.NewTicker(15 * time.Second)
	defer keepalive.Stop()

	// UseProtoNames keeps field names in the wire protobuf-native
	// snake_case (e.g. "instance_stats") so the dashboard JS doesn't
	// have to know which side camelCase'd what.
	marshaler := protojson.MarshalOptions{EmitUnpopulated: true, UseProtoNames: true}

	// Last-Event-ID resume: EventSource sends the header automatically on
	// reconnect; ?last_event_id= supports manual/non-browser resumers. We
	// replay the retained events after that id (each stamped with its id so
	// the client's cursor keeps advancing), noting a gap when the ring has
	// already overwritten the resume point — the snapshot below resyncs
	// coarse state either way.
	var replayMax uint64
	lastIDRaw := r.Header.Get("Last-Event-ID")
	if lastIDRaw == "" {
		lastIDRaw = r.URL.Query().Get("last_event_id")
	}
	if lastID, err := strconv.ParseUint(lastIDRaw, 10, 64); err == nil && lastID > 0 {
		replayed, gapless := h.bus.Replay(lastID, filter)
		if !gapless {
			_, _ = fmt.Fprint(w, ": replay-gap\n\n")
		}
		for _, st := range replayed {
			body, err := marshaler.Marshal(st.Event)
			if err != nil {
				continue
			}
			if _, err := fmt.Fprintf(w, "id: %d\ndata: %s\n\n", st.ID, body); err != nil {
				return
			}
			replayMax = st.ID
		}
		flusher.Flush()
	}

	// Initial state snapshot (instances + agents, same DTO shapes as the
	// REST polls) as a NAMED event — EventSource.onmessage only fires for
	// unnamed events, so pre-snapshot clients ignore it for free. Skipped
	// on per-instance streams (consoles) where it's dead weight.
	if filter.InstanceID == "" {
		if snap, err := h.buildStateSnapshot(ctx); err == nil {
			if _, err := fmt.Fprintf(w, "event: snapshot\ndata: %s\n\n", snap); err != nil {
				return
			}
			flusher.Flush()
		} else {
			h.log.Warn("SSE snapshot build failed", "err", err)
		}
	}

	for {
		select {
		case <-ctx.Done():
			return
		case <-keepalive.C:
			// ":" is an SSE comment line; keeps intermediaries from killing the conn.
			_, err := fmt.Fprint(w, ": keepalive\n\n")
			if err != nil {
				return
			}
			flusher.Flush()
		case st, ok := <-sub.Stamped():
			if !ok {
				return
			}
			if st.ID <= replayMax {
				continue // already sent during replay (published pre-subscribe overlap)
			}
			body, err := marshaler.Marshal(st.Event)
			if err != nil {
				continue
			}
			if _, err := fmt.Fprintf(w, "id: %d\ndata: %s\n\n", st.ID, body); err != nil {
				return
			}
			flusher.Flush()
		}
	}
}

// buildStateSnapshot marshals the dashboard's coarse world state — the same
// payloads GET /api/instances + GET /api/agents return — for the SSE
// connect snapshot. Single JSON object on one line (SSE data framing).
func (h *httpServer) buildStateSnapshot(ctx context.Context) ([]byte, error) {
	instances, err := h.buildInstanceDTOs(ctx, "")
	if err != nil {
		return nil, err
	}
	return json.Marshal(map[string]any{
		"instances": instances,
		"agents":    h.buildAgentDTOs(ctx),
	})
}

// keep errors imported for future use
var _ = errors.New