package main

// ARK: Survival Ascended cluster manager.
//
// AMP-style one-click clustering: an ASA server can "Setup Cluster" to
// create a new shared cluster directory, then other ASA 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 `panel-ark-cluster-<clusterID>` volume
//      (via InstanceCreate.mount_overrides).
//   2. Set the instance's CLUSTER_ID env var (via ConfigValues) so the
//      acekorneya image passes `-clusterid=<id>` to the ASA binary.
//
// 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/ark-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.
//
// `ARK_CLUSTER_DIR_CONTAINER_PATH` is the single source of truth for
// where ARK writes cluster transfer data 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"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"

	"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/ark-sa/module.yaml`. Kept here as the single place the
	// controller cares about it.
	arkClusterDirContainerPath = "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/clusters"

	arkClusterRegistryFile = "ark-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.
	arkClusterRecreateTimeout = 3 * time.Minute
)

// arkClusterRegistry 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 arkClusterRegistry struct {
	Clusters []arkClusterRecord           `json:"clusters"`
	Members  map[string]string            `json:"members"` // instance_id → cluster_id
}

type arkClusterRecord struct {
	ID        string    `json:"id"`
	Name      string    `json:"name"`
	CreatedAt time.Time `json:"created_at"`
}

type arkClusterStore struct {
	mu   sync.Mutex
	path string
}

func newArkClusterStore(dataDir string) *arkClusterStore {
	return &arkClusterStore{path: filepath.Join(dataDir, arkClusterRegistryFile)}
}

func (s *arkClusterStore) load() (arkClusterRegistry, error) {
	reg := arkClusterRegistry{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 *arkClusterStore) save(reg arkClusterRegistry) 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 ----

// handleArkClustersList returns every known cluster with its member list.
// Filters non-ARK instances defensively even though membership only gets
// recorded for ark-sa.
func (h *httpServer) handleArkClustersList(w http.ResponseWriter, r *http.Request) {
	s := h.arkClusters
	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,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"clusters": out})
}

// handleArkClusterGet returns the cluster info for a specific instance
// (or null if it isn't clustered). Used by the ARK 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) handleArkClusterGet(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.arkClusters
	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 *arkClusterRecord
	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,
		},
	})
}

type arkClusterActionReq 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"`
}

// handleArkClusterSetup 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) handleArkClusterSetup(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.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req arkClusterActionReq
	_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
	clusterID := "cl_" + randomHex(6)
	name := req.Name
	if name == "" {
		name = "ARK Cluster " + clusterID[3:]
	}
	s := h.arkClusters
	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, arkClusterRecord{ID: clusterID, Name: name, CreatedAt: time.Now()})
	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 = filterClusters(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.recreateArkWithClusterMount(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 = filterClusters(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,
	})
}

// handleArkClusterJoin 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) handleArkClusterJoin(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.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	var req arkClusterActionReq
	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.arkClusters
	s.mu.Lock()
	reg, lerr := s.load()
	if lerr != nil {
		s.mu.Unlock()
		writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
		return
	}
	var exists bool
	for _, c := range reg.Clusters {
		if c.ID == req.ClusterID {
			exists = true
			break
		}
	}
	if !exists {
		s.mu.Unlock()
		writeError(w, http.StatusNotFound, "not_found", "no such cluster")
		return
	}
	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 = filterClusters(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.recreateArkWithClusterMount(r.Context(), row, agentID, req.ClusterID); 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,
	})
}

// handleArkClusterLeave 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) handleArkClusterLeave(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.loadArkInstance(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusBadRequest, "bad_module", err.Error())
		return
	}
	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
	}
	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 = filterClusters(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.recreateArkWithClusterMount(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 filterClusters(list []arkClusterRecord, drop string) []arkClusterRecord {
	out := make([]arkClusterRecord, 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 *arkClusterStore) 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 = filterClusters(reg.Clusters, cid)
	}
	if err := s.save(reg); err != nil {
		return cid, err
	}
	return cid, nil
}

func randomHex(n int) string {
	b := make([]byte, n)
	_, _ = rand.Read(b)
	return hex.EncodeToString(b)
}

// loadArkInstance fetches the instance row and confirms module_id is ark-sa.
func (h *httpServer) loadArkInstance(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 != "ark-sa" {
				return nil, fmt.Errorf("instance %q is module %q, not ark-sa", id, rows[i].ModuleID)
			}
			return &rows[i], nil
		}
	}
	return nil, fmt.Errorf("instance %q not found", id)
}

// recreateArkWithClusterMount 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.
func (h *httpServer) recreateArkWithClusterMount(ctx context.Context, row *db.InstanceRow, agentID, clusterID 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, arkClusterRecreateTimeout)
	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 != "" {
		cfg["CLUSTER_ID"] = clusterID
	} else {
		delete(cfg, "CLUSTER_ID")
	}
	// CUSTOM_SERVER_ARGS used to set -ClusterDirOverride here as a defensive
	// override. Removed 2026-04-28: the acekorneya image's launch_ASA.sh
	// expands $CUSTOM_SERVER_ARGS unquoted, which word-splits paths
	// containing spaces into multiple argv elements (the wine-virtualized
	// install path always has spaces). The split made ARK silently treat
	// only the first chunk as the cluster path, breaking transfers. The
	// bind-mount target is the same as ARK's default cluster path
	// (Saved/clusters), so the override was never actually needed —
	// removing it makes ARK use its default, which IS the shared bind mount.
	delete(cfg, "CUSTOM_SERVER_ARGS")
	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.
	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"
	}
	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/arkcluster/<cluster_id>/` on the host to inspect, back
		// up, or manually seed cluster transfers. `$AGENT_DATA_ROOT`
		// is expanded agent-side into whatever data dir the agent was
		// started with (defaults to `./data`).
		create.MountOverrides = map[string]string{
			arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + 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
}

// arkClusterMountOverridesFor 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 *arkClusterStore) 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{
		arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
	}
}
