package dispatch

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Cluster player-save snapshots.
//
// A 7DTD cluster shares ONE Player/ folder across every member server (each
// member's active-world Player dir is a symlink to the shared /cluster/Player
// bind). That shared dir holds every character's .ttp (inventory, skills,
// position, quests) — the single most irreplaceable per-player state. But it
// is NOT captured by the panel tar backup: the backup tars /game-saves, where
// the active Player dir is a symlink pointing OUT to /cluster, so tar stores
// the dangling symlink, not the contents. Region Medic only heals world
// regions, not player files. Net result before this: one corrupt write to a
// .ttp and that character was gone with no restore path.
//
// Fix (mirrors the region-medic rolling-snapshot pattern): on every 7DTD
// start, if the instance is clustered, copy the shared Player dir to a fresh
// rolling slot keyed by cluster id and prune to keep N. All members write to
// the same per-cluster rolling history, so a member's 4×/day restart cadence
// gives a dense set of restore points. Best-effort: never blocks the start.

func clusterPlayerRollingRoot() string {
	return medicEnvOr("PANEL_CLUSTER_PLAYER_ROLLING_DIR", "/home/refuge/cluster-player-rolling")
}

func clusterPlayerKeep() int {
	if v := os.Getenv("PANEL_CLUSTER_PLAYER_KEEP"); v != "" {
		if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
			return n
		}
	}
	return 12
}

// snapshotClusterPlayerOnStart resolves the instance's /cluster bind to its
// host path, and if it's a real shared bind holding player data, snapshots
// <bind>/Player into a rolling slot keyed by the cluster id (= the bind dir's
// basename, e.g. cl_37e3a7f72cdc). No-op for standalone servers (whose
// /cluster is an empty per-instance named volume, not a bind).
func (d *Dispatcher) snapshotClusterPlayerOnStart(ctx context.Context, rec *instanceRecord) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
	defer cancel()
	log := d.log.With("instance_id", rec.InstanceID, "cluster_player_snapshot", true)

	mounts, err := d.runtime.ContainerMounts(ctx, "panel-"+rec.InstanceID)
	if err != nil {
		log.Warn("cluster-player snapshot: inspect mounts failed (skipping)", "err", err)
		return
	}
	var bind string
	for _, m := range mounts {
		if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
			bind = m.Source
			break
		}
	}
	if bind == "" {
		return // standalone server (named-volume /cluster) — nothing shared to snapshot
	}
	playerDir := filepath.Join(bind, "Player")
	if st, err := os.Stat(playerDir); err != nil || !st.IsDir() {
		return // cluster joined but no Player dir yet (first boot)
	}
	if empty, _ := dirIsEmpty(playerDir); empty {
		return
	}

	clusterID := filepath.Base(bind)
	destRoot := filepath.Join(clusterPlayerRollingRoot(), clusterID)
	if err := os.MkdirAll(destRoot, 0o755); err != nil {
		log.Warn("cluster-player snapshot: mkdir rolling root failed", "err", err)
		return
	}
	slot := filepath.Join(destRoot, "slot-"+strconv.FormatInt(time.Now().Unix(), 10))
	dst := filepath.Join(slot, "Player")
	if err := os.MkdirAll(slot, 0o755); err != nil {
		log.Warn("cluster-player snapshot: mkdir slot failed", "err", err)
		return
	}
	// cp -a preserves perms/acls/timestamps and the dir tree (player subdirs
	// of .7rm map files). figaro is Linux; cp is always present. Best-effort.
	cmd := exec.CommandContext(ctx, "cp", "-a", playerDir+"/.", dst)
	if out, err := cmd.CombinedOutput(); err != nil {
		log.Warn("cluster-player snapshot: copy failed", "err", err, "out", strings.TrimSpace(string(out)))
		_ = os.RemoveAll(slot) // don't leave a half-copied slot that prune would keep
		return
	}
	keep := clusterPlayerKeep()
	pruned := pruneRollingSlots(destRoot, keep)
	log.Info("cluster-player snapshot taken", "cluster", clusterID, "slot", filepath.Base(slot), "keep", keep, "pruned", pruned)
}

// dirIsEmpty reports whether dir has no entries.
func dirIsEmpty(dir string) (bool, error) {
	f, err := os.Open(dir)
	if err != nil {
		return false, err
	}
	defer f.Close()
	names, err := f.Readdirnames(1)
	if err != nil {
		// io.EOF → empty.
		return len(names) == 0, nil
	}
	return len(names) == 0, nil
}

// pruneRollingSlots keeps the newest `keep` slot-* dirs under root (by name,
// which embeds a unix timestamp so lexical == chronological) and removes the
// rest. Returns how many it deleted.
func pruneRollingSlots(root string, keep int) int {
	entries, err := os.ReadDir(root)
	if err != nil {
		return 0
	}
	var slots []string
	for _, e := range entries {
		if e.IsDir() && strings.HasPrefix(e.Name(), "slot-") {
			slots = append(slots, e.Name())
		}
	}
	if len(slots) <= keep {
		return 0
	}
	sort.Strings(slots) // oldest first
	removed := 0
	for _, name := range slots[:len(slots)-keep] {
		if err := os.RemoveAll(filepath.Join(root, name)); err == nil {
			removed++
		}
	}
	return removed
}
