package main

// 7 Days to Die cluster manager.
//
// AMP-style one-click clustering: a 7DTD server can "Setup Cluster" to
// create a new shared cluster directory, then other 7DTD servers on the
// same agent can "Join Cluster" to mount the same directory. Under the
// hood we:
//   1. Swap the instance's default per-instance `panel-<id>-cluster`
//      named volume for a shared host bind mount at `/cluster`
//      (via InstanceCreate.mount_overrides).
//   2. Set the instance's CLUSTER_ID env var (via ConfigValues) so the
//      panel-7dtd entrypoint symlinks this world's per-instance
//      `Saves/<GameWorld>/<GameName>/Player` folder → `/cluster/Player`,
//      sharing player progression across every member of the cluster.
//
// The instance's container has to be recreated for the volume swap to
// take effect (Docker can't add mounts to a live container), so the
// join/leave flow mirrors the existing changePorts dance: stop → delete
// (preserve volumes) → recreate with override → start.
//
// Membership state is derived authoritatively from one JSON file:
// `$data_dir/7dtd-clusters.json`. Keeping it outside the Postgres schema
// avoids a migration for a feature that isn't that load-bearing. If the
// JSON disappears or gets corrupted, clusters still *work* (the env var
// + shared volume are set on each member's container spec) — the UI just
// can't render the relationship until a fresh "Setup Cluster" happens.
//
// `sevenDtdClusterDirContainerPath` is the single source of truth for
// where the shared cluster dir lands inside the container. If the
// module.yaml changes, this constant must match or the mount override
// will land at the wrong container path.

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"

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

	"github.com/dbledeez/panel/controller/internal/db"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

const (
	// Must match the `container:` field of the cluster volume entry in
	// `modules/7dtd/module.yaml`. The 7DTD entrypoint symlinks this
	// world's per-instance Player save folder into /cluster/Player when
	// CLUSTER_ID is set, so every member shares player progression.
	sevenDtdClusterDirContainerPath = "/cluster"

	sevenDtdClusterRegistryFile = "7dtd-clusters.json"

	// Max time we'll spend on a single stop/delete/create/start cycle
	// when (un)joining a cluster. SteamCMD-free so this should finish
	// fast; generous budget covers Docker Desktop cold-starts.
	sevenDtdClusterRecreateTimeout = 3 * time.Minute
)

// sevenDtdClusterRegistry is the JSON-on-disk view. Cluster membership is
// also implied by the CLUSTER_ID env var on each instance's container —
// the registry exists purely to persist human-readable names and
// timestamps that don't have another home.
type sevenDtdClusterRegistry struct {
	Clusters []sevenDtdClusterRecord `json:"clusters"`
	Members  map[string]string       `json:"members"` // instance_id → cluster_id
}

type sevenDtdClusterRecord struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
	// Master is the instance that ran "Setup Cluster" — the cluster's
	// source of truth. Its Mods set is what "seed mods from master" clones
	// onto a joining server. Empty for clusters created before this field
	// existed (callers fall back to any other member).
	Master string `json:"master,omitempty"`
}

type sevenDtdClusterStore struct {
	mu   sync.Mutex
	path string
}

func newSevenDtdClusterStore(dataDir string) *sevenDtdClusterStore {
	return &sevenDtdClusterStore{path: filepath.Join(dataDir, sevenDtdClusterRegistryFile)}
}

func (s *sevenDtdClusterStore) load() (sevenDtdClusterRegistry, error) {
	reg := sevenDtdClusterRegistry{Members: map[string]string{}}
	b, err := os.ReadFile(s.path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return reg, nil
		}
		return reg, err
	}
	if err := json.Unmarshal(b, &reg); err != nil {
		return reg, err
	}
	if reg.Members == nil {
		reg.Members = map[string]string{}
	}
	return reg, nil
}

func (s *sevenDtdClusterStore) save(reg sevenDtdClusterRegistry) error {
	sort.Slice(reg.Clusters, func(i, j int) bool { return reg.Clusters[i].CreatedAt.Before(reg.Clusters[j].CreatedAt) })
	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
		return err
	}
	b, err := json.MarshalIndent(reg, "", "  ")
	if err != nil {
		return err
	}
	// Write+rename for atomicity — a crashed half-write would otherwise
	// leave the registry unreadable, and we'd lose names.
	tmp := s.path + ".tmp"
	if err := os.WriteFile(tmp, b, 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, s.path)
}

// ---- HTTP handlers ----

// handle7dtdClustersList returns every known cluster with its member list.
// Filters non-7DTD instances defensively even though membership only gets
// recorded for 7dtd.
func (h *httpServer) handle7dtdClustersList(w http.ResponseWriter, r *http.Request) {
	s := h.sevenDtdClusters
	s.mu.Lock()
	reg, err := s.load()
	s.mu.Unlock()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
		return
	}
	// Invert members map for fast per-cluster lookup.
	byCluster := map[string][]string{}
	for inst, cid := range reg.Members {
		byCluster[cid] = append(byCluster[cid], inst)
	}
	out := make([]map[string]any, 0, len(reg.Clusters))
	for _, c := range reg.Clusters {
		members := byCluster[c.ID]
		sort.Strings(members)
		out = append(out, map[string]any{
			"id":         c.ID,
			"name":       c.Name,
			"created_at": c.CreatedAt.UTC().Format(time.RFC3339),
			"members":    members,
			"master":     c.Master,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"clusters": out})
}

// handle7dtdClusterGet returns the cluster info for a specific instance
// (or null if it isn't clustered). Used by the 7DTD modal to decide
// whether to show "Setup Cluster" vs "Leave Cluster".
//
// Also returns a `mount_drift` flag — true when the registry says the
// instance is in a cluster but the agent has no record of the recreate
// having succeeded (== container still has the per-instance cluster
// volume instead of the shared bind mount). Lets the UI surface a
// "this cluster bond didn't take — re-join to repair" warning.
func (h *httpServer) handle7dtdClusterGet(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	if _, err := h.lookupInstance(r, id); err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	s := h.sevenDtdClusters
	s.mu.Lock()
	reg, err := s.load()
	s.mu.Unlock()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
		return
	}
	cid := reg.Members[id]
	if cid == "" {
		writeJSON(w, http.StatusOK, map[string]any{"cluster": nil})
		return
	}
	var rec *sevenDtdClusterRecord
	for i := range reg.Clusters {
		if reg.Clusters[i].ID == cid {
			rec = &reg.Clusters[i]
			break
		}
	}
	if rec == nil {
		writeJSON(w, http.StatusOK, map[string]any{"cluster": map[string]any{"id": cid, "name": "(missing record)"}})
		return
	}
	// Siblings = other members of the same cluster.
	siblings := []string{}
	for inst, mcid := range reg.Members {
		if mcid == cid && inst != id {
			siblings = append(siblings, inst)
		}
	}
	sort.Strings(siblings)
	// Drift detection: read the persisted DB row and check whether
	// CLUSTER_ID matches what the registry expects. If the recreate
	// dance was rolled back / failed, the row's ConfigValues won't
	// have CLUSTER_ID set even though the registry says it should.
	mountDrift := false
	rows, _ := h.db.ListInstances(r.Context(), "")
	for _, row := range rows {
		if row.InstanceID == id {
			cv := row.ConfigValues
			if cv == nil || cv["CLUSTER_ID"] != cid {
				mountDrift = true
			}
			break
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"cluster": map[string]any{
			"id":          rec.ID,
			"name":        rec.Name,
			"created_at":  rec.CreatedAt.UTC().Format(time.RFC3339),
			"siblings":    siblings,
			"mount_drift": mountDrift,
			"master":      rec.Master,
			"is_master":   rec.Master == id,
		},
	})
}

type sevenDtdClusterActionReq struct {
	// For "create new" flow: operator-supplied name (optional).
	Name string `json:"name,omitempty"`
	// For "join existing" flow: target cluster id.
	ClusterID string `json:"cluster_id,omitempty"`
	// For "join existing" flow: when true, clone the cluster master's Mods
	// set onto this joining server (replacing its mods) as part of the join
	// recreate. Per-server state (RefugeBot homes.json) is preserved.
	SeedMods bool `json:"seed_mods,omitempty"`
}

// handle7dtdClusterSetup creates a brand new cluster with the target
// instance as its first member. Generates a cluster id, writes the
// registry, and triggers the recreate dance with the new mount override.
func (h *httpServer) handle7dtdClusterSetup(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.load7dtdInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req sevenDtdClusterActionReq
	_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
	clusterID := "cl_" + randomHex(6)
	name := req.Name
	if name == "" {
		name = "7DTD Cluster " + clusterID[3:]
	}
	s := h.sevenDtdClusters
	s.mu.Lock()
	reg, err := s.load()
	if err != nil {
		s.mu.Unlock()
		writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
		return
	}
	// Kick the instance out of any prior cluster first (no-op if none).
	prior := reg.Members[id]
	delete(reg.Members, id)
	reg.Clusters = append(reg.Clusters, sevenDtdClusterRecord{ID: clusterID, Name: name, CreatedAt: time.Now(), Master: id})
	reg.Members[id] = clusterID
	// If the prior cluster was orphaned (only-member instance just
	// left) drop the record too, otherwise operators end up with a
	// cloud of empty clusters.
	if prior != "" {
		stillUsed := false
		for _, v := range reg.Members {
			if v == prior {
				stillUsed = true
				break
			}
		}
		if !stillUsed {
			reg.Clusters = filter7dtdClusters(reg.Clusters, prior)
		}
	}
	if err := s.save(reg); err != nil {
		s.mu.Unlock()
		writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
		return
	}
	s.mu.Unlock()

	if err := h.recreate7dtdWithClusterMount(r.Context(), row, agentID, clusterID, ""); err != nil {
		// Roll back the registry write — without this, a failed recreate
		// leaves the instance "in" the cluster on disk while the actual
		// container still has the old per-instance volume. Future joins
		// of OTHER instances would then operate on a phantom registry.
		s.mu.Lock()
		if rb, lerr := s.load(); lerr == nil {
			delete(rb.Members, id)
			if prior != "" {
				stillUsed := false
				for _, v := range rb.Members {
					if v == prior {
						stillUsed = true
						break
					}
				}
				if stillUsed {
					rb.Members[id] = prior
				}
			}
			// Drop the new cluster we just added if no one else is using it.
			used := false
			for _, v := range rb.Members {
				if v == clusterID {
					used = true
					break
				}
			}
			if !used {
				rb.Clusters = filter7dtdClusters(rb.Clusters, clusterID)
			}
			_ = s.save(rb)
		}
		s.mu.Unlock()
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":           true,
		"cluster_id":   clusterID,
		"cluster_name": name,
		"instance_id":  id,
	})
}

// handle7dtdClusterJoin adds the instance to an existing cluster. Same
// recreate dance — the mount override points at the same shared volume
// that every other member of the cluster already uses.
func (h *httpServer) handle7dtdClusterJoin(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.load7dtdInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req sevenDtdClusterActionReq
	if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil || req.ClusterID == "" {
		writeError(w, http.StatusBadRequest, "bad_json", "cluster_id is required")
		return
	}
	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
	}
	var rec *sevenDtdClusterRecord
	for i := range reg.Clusters {
		if reg.Clusters[i].ID == req.ClusterID {
			rec = &reg.Clusters[i]
			break
		}
	}
	if rec == nil {
		s.mu.Unlock()
		writeError(w, http.StatusNotFound, "not_found", "no such cluster")
		return
	}
	// VERSION GUARD — refuse a cross-version cluster join. The cluster's golden
	// rule (CLUSTER_PLAYBOOK §2) requires every member on the byte-identical
	// game build. A member on a different Steam branch — e.g. a 3.0-experimental
	// server joining a 2.6 cluster — morphs the shared player inventory and NREs
	// the world on load (the .nim/deco id tables diverge). We can't auto-fix
	// that, so refuse: the joiner's branch must equal every existing member's.
	joinerBranch := sevenDtdBranchKey(row.ConfigValues["provider_id"])
	for inst, cid := range reg.Members {
		if cid != req.ClusterID || inst == id {
			continue
		}
		mrow, mErr := h.db.GetInstanceRow(r.Context(), inst)
		if mErr != nil {
			continue // transient read miss on one member — don't hard-fail the join
		}
		memberBranch := sevenDtdBranchKey(mrow.ConfigValues["provider_id"])
		if memberBranch != joinerBranch {
			s.mu.Unlock()
			writeError(w, http.StatusConflict, "cluster_version_mismatch",
				fmt.Sprintf("cannot join cluster: this server's game version is %q but member %q is on %q. Every cluster member must run the identical build — shared player saves morph/corrupt across versions. Switch this server to %q first, or test the other version on a standalone server.",
					joinerBranch, inst, memberBranch, memberBranch))
			return
		}
	}
	// Resolve the mod-seed source while we still hold the registry lock: the
	// recorded master, or (for pre-master clusters) any other existing
	// member. Never seed from the joining instance itself.
	seedFrom := ""
	if req.SeedMods {
		seedFrom = rec.Master
		if seedFrom == "" || seedFrom == id {
			seedFrom = ""
			for inst, cid := range reg.Members {
				if cid == req.ClusterID && inst != id {
					seedFrom = inst
					break
				}
			}
		}
	}
	prior := reg.Members[id]
	reg.Members[id] = req.ClusterID
	if prior != "" && prior != req.ClusterID {
		stillUsed := false
		for _, v := range reg.Members {
			if v == prior {
				stillUsed = true
				break
			}
		}
		if !stillUsed {
			reg.Clusters = filter7dtdClusters(reg.Clusters, prior)
		}
	}
	if err := s.save(reg); err != nil {
		s.mu.Unlock()
		writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
		return
	}
	s.mu.Unlock()

	if err := h.recreate7dtdWithClusterMount(r.Context(), row, agentID, req.ClusterID, seedFrom); err != nil {
		// Same rollback contract as Setup — never leave a phantom membership.
		s.mu.Lock()
		if rb, lerr := s.load(); lerr == nil {
			if prior == "" {
				delete(rb.Members, id)
			} else {
				rb.Members[id] = prior
			}
			_ = s.save(rb)
		}
		s.mu.Unlock()
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":          true,
		"cluster_id":  req.ClusterID,
		"instance_id": id,
	})
}

// handle7dtdClusterLeave removes the instance from its cluster and
// recreates the container with the default per-instance cluster volume
// (no mount override, no CLUSTER_ID env). The shared cluster volume is
// left intact on disk so other members can keep using it; if this was
// the last member, we drop the cluster record from the registry.
func (h *httpServer) handle7dtdClusterLeave(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.load7dtdInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	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
	}
	prior := reg.Members[id]
	if prior == "" {
		s.mu.Unlock()
		writeJSON(w, http.StatusOK, map[string]any{"ok": true, "already_standalone": true})
		return
	}
	delete(reg.Members, id)
	stillUsed := false
	for _, v := range reg.Members {
		if v == prior {
			stillUsed = true
			break
		}
	}
	if !stillUsed {
		reg.Clusters = filter7dtdClusters(reg.Clusters, prior)
	}
	if err := s.save(reg); err != nil {
		s.mu.Unlock()
		writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
		return
	}
	s.mu.Unlock()

	if err := h.recreate7dtdWithClusterMount(r.Context(), row, agentID, "", ""); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true, "instance_id": id})
}

// ---- helpers ----

func filter7dtdClusters(list []sevenDtdClusterRecord, drop string) []sevenDtdClusterRecord {
	out := make([]sevenDtdClusterRecord, 0, len(list))
	for _, c := range list {
		if c.ID != drop {
			out = append(out, c)
		}
	}
	return out
}

// removeInstanceFromCluster scrubs an instance's cluster membership and,
// if it was the last member of its cluster, drops the cluster record too.
// Safe to call for any instance_id (no-op if not in registry). Returns
// the cluster_id the instance was a member of (empty if it wasn't), so
// callers can log or react to the orphaning.
//
// Called on instance delete to prevent stale "already joined" state from
// leaking into a fresh instance with the same id.
func (s *sevenDtdClusterStore) removeInstanceFromCluster(instanceID string) (clusterID string, err error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	reg, err := s.load()
	if err != nil {
		return "", err
	}
	cid, ok := reg.Members[instanceID]
	if !ok {
		return "", nil
	}
	delete(reg.Members, instanceID)
	stillUsed := false
	for _, v := range reg.Members {
		if v == cid {
			stillUsed = true
			break
		}
	}
	if !stillUsed {
		reg.Clusters = filter7dtdClusters(reg.Clusters, cid)
	}
	if err := s.save(reg); err != nil {
		return cid, err
	}
	return cid, nil
}

// load7dtdInstance fetches the instance row and confirms module_id is 7dtd.
func (h *httpServer) load7dtdInstance(ctx context.Context, id string) (*db.InstanceRow, error) {
	rows, err := h.db.ListInstances(ctx, "")
	if err != nil {
		return nil, err
	}
	for i := range rows {
		if rows[i].InstanceID == id {
			if rows[i].ModuleID != "7dtd" {
				return nil, fmt.Errorf("instance %q is module %q, not 7dtd", id, rows[i].ModuleID)
			}
			return &rows[i], nil
		}
	}
	return nil, fmt.Errorf("instance %q not found", id)
}

// recreate7dtdWithClusterMount stops, deletes-with-preserve, and recreates
// the instance with (or without) the cluster mount override + CLUSTER_ID
// env. Joining a cluster passes clusterID != ""; leaving passes "". If
// the instance was running before, it's started again at the end.
// handle7dtdClusterSyncMods pushes the cluster master's CURRENT Mods/ set to
// every OTHER member of the master's cluster, live — no recreate, no restart.
// Each member keeps running on its loaded mods and picks up the freshly-copied
// files on its NEXT reboot ("copy now, apply on next restart"). Per-server
// state is respected: the master's RefugeBot homes.json is excluded from the
// copy and each member's own homes.json is preserved (see seedModsFromInstance
// on the agent).
//
// Must be triggered FROM the cluster master. The mod-seed sidecar mounts
// node-local volumes, so any member on a different agent host than the master
// is skipped (reported in the response, not silently dropped).
func (h *httpServer) handle7dtdClusterSyncMods(w http.ResponseWriter, r *http.Request) {
	masterID := r.PathValue("id")
	masterAgent, err := h.lookupInstance(r, masterID)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}

	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
	}
	clusterID := reg.Members[masterID]
	if clusterID == "" {
		s.mu.Unlock()
		writeError(w, http.StatusBadRequest, "not_clustered", "this server is not in a cluster")
		return
	}
	var rec *sevenDtdClusterRecord
	for i := range reg.Clusters {
		if reg.Clusters[i].ID == clusterID {
			rec = &reg.Clusters[i]
			break
		}
	}
	isMaster := rec != nil && rec.Master == masterID
	masterName := ""
	if rec != nil {
		masterName = rec.Master
	}
	var members []string
	for inst, cid := range reg.Members {
		if cid == clusterID && inst != masterID {
			members = append(members, inst)
		}
	}
	s.mu.Unlock()

	if !isMaster {
		writeError(w, http.StatusBadRequest, "not_master",
			fmt.Sprintf("mod sync must be run from the cluster master (%s)", masterName))
		return
	}
	conn := h.registry.get(masterAgent)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", masterAgent))
		return
	}
	sort.Strings(members)

	type memberResult struct {
		Instance string `json:"instance"`
		OK       bool   `json:"ok"`
		Mods     int    `json:"mods,omitempty"`
		Skipped  bool   `json:"skipped,omitempty"`
		Error    string `json:"error,omitempty"`
	}
	results := make([]memberResult, 0, len(members))
	synced := 0
	for _, m := range members {
		// The mod-seed sidecar mounts both instances' local volumes, so the
		// member must live on the same agent as the master.
		mAgent, aerr := h.lookupInstance(r, m)
		if aerr != nil || mAgent != masterAgent {
			results = append(results, memberResult{Instance: m, Skipped: true,
				Error: "on a different agent host — cross-host mod sync not supported"})
			continue
		}
		corrID := newCorrelationID("seedmods")
		if serr := conn.Send(&panelv1.ControllerEnvelope{
			CorrelationId: corrID,
			SentAt:        timestamppb.Now(),
			Payload: &panelv1.ControllerEnvelope_SeedMods{
				SeedMods: &panelv1.SeedModsRequest{InstanceId: m, SrcInstanceId: masterID},
			},
		}); serr != nil {
			results = append(results, memberResult{Instance: m, Error: serr.Error()})
			continue
		}
		// Each copy is a tar of the Mods tree between local volumes — fast, but
		// give it headroom for a big mod set on a busy host.
		env, aerr := h.pending.Await(r.Context(), corrID, 5*time.Minute)
		if aerr != nil {
			results = append(results, memberResult{Instance: m, Error: aerr.Error()})
			continue
		}
		res := env.GetSeedModsResult()
		if res == nil {
			results = append(results, memberResult{Instance: m, Error: "no reply from agent"})
			continue
		}
		if res.Error != "" || !res.Ok {
			emsg := res.Error
			if emsg == "" {
				emsg = "mod seed failed"
			}
			results = append(results, memberResult{Instance: m, Error: emsg})
			continue
		}
		synced++
		results = append(results, memberResult{Instance: m, OK: true, Mods: int(res.ModCount)})
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"ok":      true,
		"master":  masterID,
		"synced":  synced,
		"total":   len(members),
		"results": results,
	})
}

func (h *httpServer) recreate7dtdWithClusterMount(ctx context.Context, row *db.InstanceRow, agentID, clusterID, seedModsFromInstanceID string) error {
	conn := h.registry.get(agentID)
	if conn == nil {
		return fmt.Errorf("agent %q not connected", agentID)
	}
	wasRunning := row.Status == "running"
	cctx, cancel := context.WithTimeout(ctx, sevenDtdClusterRecreateTimeout)
	defer cancel()

	if wasRunning {
		if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
		}); err != nil {
			return fmt.Errorf("stop: %w", err)
		}
	}
	if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
	}); err != nil {
		return fmt.Errorf("delete: %w", err)
	}

	// Build the updated config_values for persistence + the create RPC.
	// We preserve whatever the operator previously set (server name,
	// admin password, etc.) and only touch the cluster-related keys.
	cfg := map[string]string{}
	for k, v := range row.ConfigValues {
		cfg[k] = v
	}
	if clusterID != "" {
		// The 7DTD entrypoint reads CLUSTER_ID to decide whether to
		// symlink this world's Player save folder into /cluster/Player.
		cfg["CLUSTER_ID"] = clusterID
	} else {
		delete(cfg, "CLUSTER_ID")
	}
	row.ConfigValues = cfg
	row.Status = "creating"
	if err := h.db.UpsertInstance(cctx, *row); err != nil {
		return fmt.Errorf("db upsert: %w", err)
	}

	// Preserve panel-allocated host ports across the recreate so the agent's
	// auto-bumper doesn't re-pick from manifest defaults and collide with
	// sibling instances on the same agent.
	var ports []*panelv1.PortMap
	for name, hp := range row.AssignedPorts {
		ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
	}
	// Suppress agent auto-start if the instance was stopped before this
	// cluster recreate. Cluster join/leave shouldn't silently boot a
	// stopped server.
	// Copy so we can inject control sentinels without mutating cfg. This
	// recreate PRESERVES the install volume — skip the create-time warm-seed +
	// first-update so a clustered 7DTD server's custom Mods survive a
	// join/leave (otherwise the recreate re-seeds /game from a sibling and
	// silently swaps the operator's mod set).
	createCV := make(map[string]string, len(cfg)+3)
	for k, v := range cfg {
		createCV[k] = v
	}
	createCV["_PANEL_SKIP_REINSTALL"] = "true"
	if !wasRunning {
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	// One-shot mod-seed: tells the agent to clone this instance's Mods set
	// from the cluster master (excluding per-server homes.json) during the
	// create, before the container starts. Never persisted (createCV only).
	if seedModsFromInstanceID != "" {
		createCV["_PANEL_SEED_MODS_FROM"] = seedModsFromInstanceID
	}
	create := &panelv1.InstanceCreate{
		InstanceId:   row.InstanceID,
		ModuleId:     row.ModuleID,
		Version:      row.ModuleVersion,
		RunMode:      panelv1.RunMode_RUN_MODE_DOCKER,
		Ports:        ports,
		ConfigValues: createCV,
	}
	if clusterID != "" {
		// Bind mount to a host-visible folder under the agent's data
		// root — matches AMP's "virtual path" UX. Operators can browse
		// `data/7dtdcluster/<cluster_id>/` on the host to inspect, back
		// up, or manually seed cluster transfers. `$PANEL_DATA_ROOT`
		// is expanded agent-side into whatever data dir the agent was
		// started with (defaults to `./data`).
		create.MountOverrides = map[string]string{
			sevenDtdClusterDirContainerPath: "$PANEL_DATA_ROOT/7dtdcluster/" + clusterID,
		}
	}
	if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: create,
	}); err != nil {
		return fmt.Errorf("create: %w", err)
	}
	if wasRunning {
		if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
		}); err != nil {
			return fmt.Errorf("start: %w", err)
		}
	}
	return nil
}

// mountOverridesFor returns the mount_overrides map needed to keep
// instanceID attached to its shared cluster bind mount across a container
// recreate. Returns nil when the instance isn't a cluster member (or when
// the registry is unreadable — caller treats nil as "no override needed",
// same as a non-clustered instance).
//
// Every recreate-dance path (env-config Apply, port change, mod toggle,
// cluster join/leave) MUST consult this and pass the result as the
// InstanceCreate's MountOverrides; otherwise the recreate replaces the
// shared bind mount with the manifest-default per-instance volume and
// silently disconnects the server from the cluster.
func (s *sevenDtdClusterStore) mountOverridesFor(instanceID string) map[string]string {
	s.mu.Lock()
	reg, err := s.load()
	s.mu.Unlock()
	if err != nil {
		return nil
	}
	cid := reg.Members[instanceID]
	if cid == "" {
		return nil
	}
	return map[string]string{
		sevenDtdClusterDirContainerPath: "$PANEL_DATA_ROOT/7dtdcluster/" + cid,
	}
}
