package main

// Valheim mod browser — Thunderstore backend.
//
// Valheim mods load through BepInEx (a Unity mod framework injected at launch
// by Doorstop). The mod ecosystem lives on Thunderstore. This mirrors the
// existing ARK (CurseForge) / Conan (Workshop) / 7DTD (Nexus) browsers:
//
//   GET    /api/valheim/mods/search?q=...&serveronly=1   → Thunderstore search
//   GET    /api/valheim/mods/detail?full_name=...        → one package's detail
//   GET    /api/instances/{id}/valheim/mods              → installed mods
//   POST   /api/instances/{id}/valheim/mods              → install {full_name}
//   DELETE /api/instances/{id}/valheim/mods/{slug}       → uninstall
//
// Install pipeline (controller does ALL the work, like the 7DTD/Nexus path):
//   1. Resolve the package's latest version + download_url from Thunderstore.
//   2. Ensure BepInEx is installed first (the loader every mod needs).
//   3. Download the mod zip → extract → fsWriteToAgent each file into the
//      instance's /game/BepInEx/plugins/<owner-name>/ tree.
//   4. Flip VALHEIM_MODS_ENABLED=1 in config_values so the entrypoint injects
//      Doorstop on next (re)start.
//
// The full Thunderstore Valheim package list (~156MB, ~9800 packages) is
// fetched once and cached in-process; search filters that cache. Refreshed
// lazily after `thunderstoreCacheTTL`.

import (
	"archive/zip"
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
	"google.golang.org/protobuf/types/known/timestamppb"
)

// --- Thunderstore types (subset of the v1 package API) --------------------

type tsPackageVersion struct {
	Name          string   `json:"name"`
	FullName      string   `json:"full_name"`
	VersionNumber string   `json:"version_number"`
	DownloadURL   string   `json:"download_url"`
	Icon          string   `json:"icon"`
	Description   string   `json:"description"`
	Dependencies  []string `json:"dependencies"`
	Downloads     int64    `json:"downloads"`
}

type tsPackage struct {
	Name         string             `json:"name"`
	FullName     string             `json:"full_name"`
	Owner        string             `json:"owner"`
	PackageURL   string             `json:"package_url"`
	IsPinned     bool               `json:"is_pinned"`
	IsDeprecated bool               `json:"is_deprecated"`
	RatingScore  int                `json:"rating_score"`
	Categories   []string           `json:"categories"`
	Versions     []tsPackageVersion `json:"versions"`
}

func (p tsPackage) latest() (tsPackageVersion, bool) {
	if len(p.Versions) == 0 {
		return tsPackageVersion{}, false
	}
	return p.Versions[0], true // Thunderstore lists newest first
}

func (p tsPackage) totalDownloads() int64 {
	var n int64
	for _, v := range p.Versions {
		n += v.Downloads
	}
	return n
}

func (p tsPackage) serverSide() bool {
	for _, c := range p.Categories {
		if strings.EqualFold(c, "Server-side") {
			return true
		}
	}
	return false
}

func (p tsPackage) clientSide() bool {
	for _, c := range p.Categories {
		if strings.EqualFold(c, "Client-side") {
			return true
		}
	}
	return false
}

// --- Thunderstore package cache -------------------------------------------

const (
	thunderstoreListURL  = "https://thunderstore.io/c/valheim/api/v1/package/"
	thunderstoreCacheTTL = 30 * time.Minute
	// The canonical BepInEx loader pack on Thunderstore. Most mods depend on
	// this; we install it first whenever a mod is added.
	bepinexFullName = "denikson-BepInExPack_Valheim"
)

type thunderstoreCache struct {
	mu       sync.Mutex
	packages []tsPackage
	byName   map[string]*tsPackage
	fetched  time.Time
}

var tsCache = &thunderstoreCache{byName: map[string]*tsPackage{}}

// thunderstoreFetch does a GET with a browser UA + redirect following — the
// Valheim community endpoint 302s through Cloudflare and rejects non-browser
// UAs otherwise.
func thunderstoreFetch(ctx context.Context, u string, timeout time.Duration) ([]byte, error) {
	cctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	req, err := http.NewRequestWithContext(cctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (panel valheim mod browser)")
	req.Header.Set("Accept", "application/json")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("thunderstore %d", resp.StatusCode)
	}
	return io.ReadAll(io.LimitReader(resp.Body, 256<<20)) // 256MB cap
}

// ensureCatalog returns the cached package list, refreshing if stale/empty.
func (c *thunderstoreCache) ensure(ctx context.Context) ([]tsPackage, error) {
	c.mu.Lock()
	fresh := time.Since(c.fetched) < thunderstoreCacheTTL && len(c.packages) > 0
	if fresh {
		pkgs := c.packages
		c.mu.Unlock()
		return pkgs, nil
	}
	c.mu.Unlock()

	body, err := thunderstoreFetch(ctx, thunderstoreListURL, 60*time.Second)
	if err != nil {
		// On refresh failure, serve a stale cache if we have one.
		c.mu.Lock()
		defer c.mu.Unlock()
		if len(c.packages) > 0 {
			return c.packages, nil
		}
		return nil, err
	}
	var pkgs []tsPackage
	if err := json.Unmarshal(body, &pkgs); err != nil {
		return nil, fmt.Errorf("decode thunderstore list: %w", err)
	}
	byName := make(map[string]*tsPackage, len(pkgs))
	for i := range pkgs {
		byName[pkgs[i].FullName] = &pkgs[i]
	}
	c.mu.Lock()
	c.packages, c.byName, c.fetched = pkgs, byName, time.Now()
	c.mu.Unlock()
	return pkgs, nil
}

func (c *thunderstoreCache) lookup(ctx context.Context, fullName string) (*tsPackage, error) {
	if _, err := c.ensure(ctx); err != nil {
		return nil, err
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if p, ok := c.byName[fullName]; ok {
		return p, nil
	}
	return nil, fmt.Errorf("package %q not found", fullName)
}

// --- search ----------------------------------------------------------------

type tsSearchResult struct {
	FullName    string `json:"full_name"`
	Name        string `json:"name"`
	Owner       string `json:"owner"`
	Version     string `json:"version"`
	Description string `json:"description"`
	Icon        string `json:"icon"`
	Downloads   int64  `json:"downloads"`
	Rating      int    `json:"rating"`
	Pinned      bool   `json:"pinned"`
	Deprecated  bool   `json:"deprecated"`
	ServerSide  bool   `json:"server_side"`
	ClientSide  bool   `json:"client_side"`
	PackageURL  string `json:"package_url"`
}

func (h *httpServer) handleValheimModSearch(w http.ResponseWriter, r *http.Request) {
	pkgs, err := tsCache.ensure(r.Context())
	if err != nil {
		writeError(w, http.StatusBadGateway, "thunderstore", err.Error())
		return
	}
	q := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q")))
	serverOnly := r.URL.Query().Get("serveronly") == "1"

	type scored struct {
		res   tsSearchResult
		score int64
	}
	var out []scored
	for i := range pkgs {
		p := &pkgs[i]
		if p.IsDeprecated {
			continue
		}
		if serverOnly && !p.serverSide() {
			continue
		}
		if q != "" {
			hay := strings.ToLower(p.Name + " " + p.Owner + " " + strings.Join(p.Categories, " "))
			if !strings.Contains(hay, q) {
				continue
			}
		}
		v, ok := p.latest()
		if !ok {
			continue
		}
		out = append(out, scored{
			res: tsSearchResult{
				FullName:    p.FullName,
				Name:        p.Name,
				Owner:       p.Owner,
				Version:     v.VersionNumber,
				Description: v.Description,
				Icon:        v.Icon,
				Downloads:   p.totalDownloads(),
				Rating:      p.RatingScore,
				Pinned:      p.IsPinned,
				Deprecated:  p.IsDeprecated,
				ServerSide:  p.serverSide(),
				ClientSide:  p.clientSide(),
				PackageURL:  p.PackageURL,
			},
			score: p.totalDownloads(),
		})
	}
	// Rank: pinned first, then by total downloads.
	sort.Slice(out, func(i, j int) bool {
		if out[i].res.Pinned != out[j].res.Pinned {
			return out[i].res.Pinned
		}
		return out[i].score > out[j].score
	})
	limit := 60
	results := make([]tsSearchResult, 0, limit)
	for i := 0; i < len(out) && i < limit; i++ {
		results = append(results, out[i].res)
	}
	writeJSON(w, http.StatusOK, map[string]any{"results": results, "total": len(out)})
}

func (h *httpServer) handleValheimModDetail(w http.ResponseWriter, r *http.Request) {
	full := r.URL.Query().Get("full_name")
	if full == "" {
		writeError(w, http.StatusBadRequest, "missing", "full_name required")
		return
	}
	p, err := tsCache.lookup(r.Context(), full)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	v, _ := p.latest()
	writeJSON(w, http.StatusOK, map[string]any{
		"full_name":    p.FullName,
		"name":         p.Name,
		"owner":        p.Owner,
		"version":      v.VersionNumber,
		"description":  v.Description,
		"icon":         v.Icon,
		"downloads":    p.totalDownloads(),
		"rating":       p.RatingScore,
		"categories":   p.Categories,
		"dependencies": v.Dependencies,
		"server_side":  p.serverSide(),
		"client_side":  p.clientSide(),
		"package_url":  p.PackageURL,
		"download_url": v.DownloadURL,
	})
}

// --- installed-mods state (stored in config_values) ------------------------
//
// We track installed mods in the instance config_values key VALHEIM_MODS as a
// JSON array of {full_name, version, dir}. This persists across recreates and
// lets the UI list/uninstall without re-reading the volume.

const valheimModsKey = "VALHEIM_MODS"

type installedMod struct {
	FullName string `json:"full_name"`
	Version  string `json:"version"`
	Dir      string `json:"dir"` // relative under BepInEx/plugins
}

func readInstalledMods(cv map[string]string) []installedMod {
	var mods []installedMod
	if s := cv[valheimModsKey]; s != "" {
		_ = json.Unmarshal([]byte(s), &mods)
	}
	return mods
}

func (h *httpServer) handleValheimModsList(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	row, err := h.valheimRow(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"mods": readInstalledMods(row.ConfigValues)})
}

func (h *httpServer) handleValheimModInstall(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 struct {
		FullName string `json:"full_name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FullName == "" {
		writeError(w, http.StatusBadRequest, "bad_json", "full_name required")
		return
	}
	pkg, err := tsCache.lookup(r.Context(), req.FullName)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
	defer cancel()

	// 1. BepInEx loader first (idempotent; skips if the package IS BepInEx).
	if req.FullName != bepinexFullName {
		if err := h.ensureBepInEx(ctx, agentID, id); err != nil {
			writeError(w, http.StatusBadGateway, "bepinex", "install BepInEx: "+err.Error())
			return
		}
	}

	// 2. Install the requested package.
	dir, ver, err := h.installThunderstorePackage(ctx, agentID, id, pkg)
	if err != nil {
		writeError(w, http.StatusBadGateway, "install", err.Error())
		return
	}

	// 3. Record in config_values + enable the mod loader.
	row, err := h.valheimRow(ctx, id)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	mods := readInstalledMods(row.ConfigValues)
	replaced := false
	for i := range mods {
		if mods[i].FullName == req.FullName {
			mods[i] = installedMod{FullName: req.FullName, Version: ver, Dir: dir}
			replaced = true
			break
		}
	}
	if !replaced {
		mods = append(mods, installedMod{FullName: req.FullName, Version: ver, Dir: dir})
	}
	modsJSON, _ := json.Marshal(mods)
	if err := h.mergeValheimConfig(ctx, id, map[string]string{
		valheimModsKey:         string(modsJSON),
		"VALHEIM_MODS_ENABLED": "1",
	}); err != nil {
		writeError(w, http.StatusInternalServerError, "save", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"installed": req.FullName, "version": ver, "dir": dir,
		"note": "restart the server to load the mod",
	})
}

func (h *httpServer) handleValheimModUninstall(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	slug := r.PathValue("slug") // full_name with '/' encoded; we accept full_name
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	row, err := h.valheimRow(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	mods := readInstalledMods(row.ConfigValues)
	var target *installedMod
	kept := mods[:0]
	for i := range mods {
		if mods[i].FullName == slug {
			m := mods[i]
			target = &m
			continue
		}
		kept = append(kept, mods[i])
	}
	if target == nil {
		writeError(w, http.StatusNotFound, "not_found", "mod not installed")
		return
	}

	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute)
	defer cancel()
	// Remove the mod's plugin directory.
	if target.Dir != "" {
		_ = h.fsDeleteOnAgent(ctx, agentID, id, "/game/BepInEx/plugins/"+target.Dir)
	}

	updates := map[string]string{}
	modsJSON, _ := json.Marshal(kept)
	updates[valheimModsKey] = string(modsJSON)
	// If no mods remain (other than possibly BepInEx itself), turn the loader
	// off so the server runs vanilla again.
	if len(kept) == 0 {
		updates["VALHEIM_MODS_ENABLED"] = "0"
		updates["VALHEIM_PLUS_ENABLED"] = "0"
	}
	if err := h.mergeValheimConfig(ctx, id, updates); err != nil {
		writeError(w, http.StatusInternalServerError, "save", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"uninstalled": slug, "note": "restart to apply"})
}

// --- install plumbing ------------------------------------------------------

// ensureBepInEx installs the BepInEx loader pack if its preloader isn't present.
// Cheap to call before every mod install; it short-circuits when already there.
func (h *httpServer) ensureBepInEx(ctx context.Context, agentID, instanceID string) error {
	// Heuristic: if the row already records a mod OR we can't tell, just (re)write
	// BepInEx — extraction is idempotent (OverwriteExistingFiles). To avoid a
	// re-download every time, we check the recorded mods list for BepInEx.
	row, err := h.valheimRow(ctx, instanceID)
	if err == nil {
		for _, m := range readInstalledMods(row.ConfigValues) {
			if m.FullName == bepinexFullName {
				return nil // already installed
			}
		}
	}
	pkg, err := tsCache.lookup(ctx, bepinexFullName)
	if err != nil {
		return err
	}
	if _, _, err := h.installThunderstorePackage(ctx, agentID, instanceID, pkg); err != nil {
		return err
	}
	// Record BepInEx so we don't re-download on the next mod.
	if row != nil {
		mods := readInstalledMods(row.ConfigValues)
		mods = append(mods, installedMod{FullName: bepinexFullName, Version: pkgVersion(pkg), Dir: ""})
		modsJSON, _ := json.Marshal(mods)
		_ = h.mergeValheimConfig(ctx, instanceID, map[string]string{valheimModsKey: string(modsJSON)})
	}
	return nil
}

func pkgVersion(p *tsPackage) string {
	if v, ok := p.latest(); ok {
		return v.VersionNumber
	}
	return ""
}

// installThunderstorePackage downloads a package's zip and writes its files into
// the instance. BepInEx pack zips contain a top-level "BepInExPack_Valheim/"
// whose CONTENTS go into /game (BepInEx/, doorstop_libs/, winhttp.dll,
// start_server_bepinex.sh, etc.). Plugin mods extract into
// /game/BepInEx/plugins/<owner-name>/. Returns the plugin dir (relative to
// plugins/) and the version.
func (h *httpServer) installThunderstorePackage(ctx context.Context, agentID, instanceID string, pkg *tsPackage) (string, string, error) {
	v, ok := pkg.latest()
	if !ok {
		return "", "", fmt.Errorf("no versions for %s", pkg.FullName)
	}
	raw, err := thunderstoreFetch(ctx, v.DownloadURL, 90*time.Second)
	if err != nil {
		return "", "", fmt.Errorf("download %s: %w", pkg.FullName, err)
	}
	zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
	if err != nil {
		return "", "", fmt.Errorf("open zip: %w", err)
	}

	isBepInEx := pkg.FullName == bepinexFullName
	// For BepInEx the pack nests everything under BepInExPack_Valheim/; strip it
	// and write the contents to /game. For plugin mods, place files under
	// BepInEx/plugins/<dir>/ — but if the zip already contains a BepInEx/plugins
	// layout, honor it (write relative to /game).
	pluginDir := sanitizeModDir(pkg.FullName)
	var writeErr error
	count := 0
	for _, f := range zr.File {
		if f.FileInfo().IsDir() {
			continue
		}
		name := f.Name
		// Thunderstore meta files we don't deploy.
		base := name[strings.LastIndex(name, "/")+1:]
		if base == "icon.png" || base == "manifest.json" || base == "README.md" || base == "CHANGELOG.md" {
			continue
		}
		// All dests are ABSOLUTE under /game (safeJoinAny on the agent matches
		// the /game browseable root; a relative path resolves under /game-saves).
		var rel string
		switch {
		case isBepInEx:
			// strip the leading top folder (e.g. "BepInExPack_Valheim/")
			rel = stripTopFolder(name)
			if rel == "" {
				continue
			}
		case strings.HasPrefix(name, "BepInEx/"):
			// zip already targets the BepInEx tree → write as-is under /game
			rel = name
		default:
			// loose plugin files → BepInEx/plugins/<dir>/<name>
			rel = "BepInEx/plugins/" + pluginDir + "/" + name
		}
		dest := "/game/" + strings.TrimPrefix(rel, "/")
		rc, err := f.Open()
		if err != nil {
			writeErr = err
			break
		}
		content, err := io.ReadAll(io.LimitReader(rc, 256<<20))
		rc.Close()
		if err != nil {
			writeErr = err
			break
		}
		if err := h.fsWriteToAgent(ctx, agentID, instanceID, dest, content); err != nil {
			writeErr = fmt.Errorf("write %s: %w", dest, err)
			break
		}
		count++
	}
	if writeErr != nil {
		return "", "", writeErr
	}
	if count == 0 {
		return "", "", fmt.Errorf("zip for %s contained no installable files", pkg.FullName)
	}
	if isBepInEx {
		return "", v.VersionNumber, nil
	}
	return pluginDir, v.VersionNumber, nil
}

// zipReader opens an in-memory zip.
func zipReader(raw []byte) (*zip.Reader, error) {
	return zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
}

// readZipFile reads one zip entry's content (capped).
func readZipFile(f *zip.File) ([]byte, error) {
	rc, err := f.Open()
	if err != nil {
		return nil, err
	}
	defer rc.Close()
	return io.ReadAll(io.LimitReader(rc, 256<<20))
}

// stripTopFolder removes the first path segment ("Top/rest" -> "rest").
func stripTopFolder(p string) string {
	if i := strings.IndexByte(p, '/'); i >= 0 {
		return p[i+1:]
	}
	return ""
}

// sanitizeModDir turns "Owner-ModName" into a safe plugin directory name.
func sanitizeModDir(fullName string) string {
	s := strings.ReplaceAll(fullName, "/", "-")
	var b strings.Builder
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
			b.WriteRune(r)
		default:
			b.WriteByte('_')
		}
	}
	return b.String()
}

// valheimRow loads an instance row, erroring unless it's a valheim instance.
func (h *httpServer) valheimRow(ctx context.Context, id string) (*db.InstanceRow, error) {
	row, err := h.db.GetInstanceRow(ctx, id)
	if err != nil {
		return nil, err
	}
	if row.ModuleID != "valheim" {
		return nil, fmt.Errorf("instance %q is not a valheim server", id)
	}
	return &row, nil
}

// mergeValheimConfig merges the given config_values into the instance and
// recreates the container so the entrypoint picks up the new env (the mod
// loader flag). Files already written to the volume survive the recreate
// (PurgeVolumes:false). Mirrors recreateArkWithMods. If the instance was
// stopped, it stays stopped (suppress autostart).
func (h *httpServer) mergeValheimConfig(ctx context.Context, id string, updates map[string]string) error {
	row, err := h.db.GetInstanceRow(ctx, id)
	if err != nil {
		return err
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		return fmt.Errorf("agent %q not connected", row.AgentID)
	}
	cfg := map[string]string{}
	for k, v := range row.ConfigValues {
		cfg[k] = v
	}
	for k, v := range updates {
		cfg[k] = v
	}
	wasRunning := row.Status == "running"
	cctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
	defer cancel()
	if wasRunning {
		if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: id, GraceSeconds: 30},
		}); err != nil {
			return fmt.Errorf("stop: %w", err)
		}
	}
	if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: id, PurgeVolumes: false},
	}); err != nil {
		return fmt.Errorf("delete: %w", err)
	}
	row.ConfigValues = cfg
	row.Status = "creating"
	if err := h.db.UpsertInstance(cctx, row); err != nil {
		return fmt.Errorf("db upsert: %w", err)
	}
	var ports []*panelv1.PortMap
	for name, hp := range row.AssignedPorts {
		ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
	}
	createCV := cfg
	if !wasRunning {
		createCV = make(map[string]string, len(cfg)+1)
		for k, v := range cfg {
			createCV[k] = v
		}
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	return h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: &panelv1.InstanceCreate{
			InstanceId:   id,
			ModuleId:     row.ModuleID,
			Version:      row.ModuleVersion,
			RunMode:      panelv1.RunMode_RUN_MODE_DOCKER,
			ConfigValues: createCV,
			Ports:        ports,
		},
	})
}

// mergeValheimConfigNoRecreate updates config_values in the DB WITHOUT
// recreating the container. Used for transient flags (install-in-progress) that
// the UI polls but that shouldn't bounce the server.
func (h *httpServer) mergeValheimConfigNoRecreate(ctx context.Context, id string, updates map[string]string) error {
	row, err := h.db.GetInstanceRow(ctx, id)
	if err != nil {
		return err
	}
	cfg := map[string]string{}
	for k, v := range row.ConfigValues {
		cfg[k] = v
	}
	for k, v := range updates {
		if v == "" {
			delete(cfg, k)
		} else {
			cfg[k] = v
		}
	}
	row.ConfigValues = cfg
	return h.db.UpsertInstance(ctx, row)
}

// fsDeleteOnAgent removes a path (recursively) inside the instance volume.
func (h *httpServer) fsDeleteOnAgent(ctx context.Context, agentID, instanceID, p string) error {
	conn := h.registry.get(agentID)
	if conn == nil {
		return fmt.Errorf("agent %q not connected", agentID)
	}
	corrID := newCorrelationID("fsdelete")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsDelete{
			FsDelete: &panelv1.FsDeleteRequest{InstanceId: instanceID, Path: p, Recursive: true},
		},
	}); err != nil {
		return err
	}
	env, err := h.pending.Await(ctx, corrID, 60*time.Second)
	if err != nil {
		return err
	}
	if res := env.GetFsDeleteResult(); res != nil && res.Error != "" {
		return fmt.Errorf("%s", res.Error)
	}
	return nil
}
