package dispatch

// ArkSaveRestore: swap a SavedArks/<subdir>/ rolling backup into the
// active <subdir>.ark slot. The previously-active save is moved aside
// (NEVER deleted) — even abandoned attempts are recoverable from the
// timestamped *_replaced.ark filename.
//
// Sequence (all server-side, atomic from the operator's POV):
//   1. Validate inputs.
//   2. Confirm the main container is NOT running. We refuse the swap
//      if it is, because mid-write file ops in ARK's save dir corrupt
//      the rolling backup chain.
//   3. mv  <subdir>/<subdir>.ark  ->  <subdir>/<subdir>_<dd.mm.yyyy>_<HH.MM.SS>_replaced.ark
//   4. cp  <subdir>/<target_filename>  ->  <subdir>/<subdir>.ark
//      For .arkrbf rolling backups the dest still ends in .ark — the
//      engine doesn't care about the source file's extension, only the
//      slot name.
//
// Caller (controller) is responsible for stopping the container before
// this RPC. If the operator started the container in the meantime,
// this handler short-circuits cleanly with a friendly error.

import (
	"context"
	"fmt"
	"io"
	"os"
	"path"
	"path/filepath"
	"strings"
	"time"

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

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// arkSavedArksContainerRoot is the relative path to ARK's rolling-save
// directory FROM the ark-sa module's BrowseableRoot. The module roots
// browsing at ".../ShooterGame/Saved" (see modules/ark-sa/module.yaml),
// so this is the segment beneath that — NOT the full container path.
const arkSavedArksContainerRoot = "SavedArks"

func (d *Dispatcher) handleArkSaveRestore(corrID string, req *panelv1.ArkSaveRestoreRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
		return
	}
	subdir := strings.TrimSpace(req.SavedArksSubdir)
	target := strings.TrimSpace(req.TargetFilename)
	if subdir == "" || target == "" {
		d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir and target_filename are required")
		return
	}
	// Path-safety: subdir is one segment under SavedArks/, target is
	// one segment under that. Reject any traversal attempt up front.
	if strings.ContainsAny(subdir, "/\\") || subdir == "." || subdir == ".." {
		d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir must be a single directory name")
		return
	}
	if strings.ContainsAny(target, "/\\") || target == "." || target == ".." {
		d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must be a single file name")
		return
	}
	// Sanity-check extension. Active slot is .ark; sources may be .ark
	// or .arkrbf (rolling backup). .bak is ARK's anti-corruption file
	// and we don't restore from it directly — operators rename to .ark
	// manually if they need to.
	lowerTarget := strings.ToLower(target)
	if !strings.HasSuffix(lowerTarget, ".ark") && !strings.HasSuffix(lowerTarget, ".arkrbf") {
		d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must end in .ark or .arkrbf")
		return
	}

	activeName := subdir + ".ark"
	if target == activeName {
		d.sendArkSaveRestoreResult(corrID, "", "", "target is already the active save")
		return
	}

	// Stamp the aside name with current wall time. We use the user's
	// "dd.mm.yyyy_HH.MM.SS" format from ARK's own rolling backups so
	// the aside file sorts naturally next to genuine engine backups
	// in the file browser. Suffix "_replaced" disambiguates them.
	// Uses UTC because that's what the engine writes — keeps the
	// frontend's parser (which assumes UTC) honest for both sources.
	now := time.Now().UTC()
	asideName := fmt.Sprintf("%s_%s_replaced.ark",
		subdir, now.Format("02.01.2006_15.04.05"))

	if d.useContainerOps(rec) {
		d.arkRestoreInContainer(corrID, rec, subdir, target, activeName, asideName)
		return
	}
	d.arkRestoreOnHost(corrID, rec, subdir, target, activeName, asideName)
}

func (d *Dispatcher) arkRestoreInContainer(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) {
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	// Refuse if the main game container is still running. We don't
	// want file ops happening inside SavedArks while the engine is
	// writing rolling backups.
	mainName := "panel-" + rec.InstanceID
	if st, err := d.runtime.InspectByName(ctx, mainName); err == nil && st.Status == "running" {
		d.sendArkSaveRestoreResult(corrID, "", "",
			"refusing to restore while server is running — stop the server first")
		return
	}

	subdirRel := path.Join(arkSavedArksContainerRoot, subdir)
	subdirAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), subdirRel)
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
		return
	}
	activeAbs := path.Join(subdirAbs, activeName)
	asideAbs := path.Join(subdirAbs, asideName)
	targetAbs := path.Join(subdirAbs, target)

	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
		return
	}

	// Verify the target file exists before we touch the active slot.
	// `test -f` returns exit 1 (no file), 0 (exists), other (error).
	_, _, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		"test -f " + shellQuote(targetAbs)})
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
		return
	}
	if code != 0 {
		d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target))
		return
	}

	// Move the previously-active save aside (only if it exists; some
	// ops may run on a freshly-installed instance with no active save).
	finalAside := ""
	_, _, code, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		"test -f " + shellQuote(activeAbs)})
	if code == 0 {
		_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			"mv -- " + shellQuote(activeAbs) + " " + shellQuote(asideAbs)})
		if err != nil {
			d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
			return
		}
		if code != 0 {
			msg := strings.TrimSpace(string(stderr))
			if msg == "" {
				msg = fmt.Sprintf("mv aside exited %d", code)
			}
			d.sendArkSaveRestoreResult(corrID, "", "", msg)
			return
		}
		finalAside = asideName
	}

	// Copy the chosen backup into the active slot. cp preserves the
	// source so the operator can pick it again later if they change
	// their mind. -p preserves mtime so the engine sees the original
	// save's modification time.
	_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		"cp -p -- " + shellQuote(targetAbs) + " " + shellQuote(activeAbs)})
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
		return
	}
	if code != 0 {
		msg := strings.TrimSpace(string(stderr))
		if msg == "" {
			msg = fmt.Sprintf("cp exited %d", code)
		}
		// Roll the aside back if cp failed mid-flight.
		if finalAside != "" {
			_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
				"mv -- " + shellQuote(asideAbs) + " " + shellQuote(activeAbs)})
			finalAside = ""
		}
		d.sendArkSaveRestoreResult(corrID, finalAside, "", msg)
		return
	}
	d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "")
}

func (d *Dispatcher) arkRestoreOnHost(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) {
	if rec.DataPath == "" {
		d.sendArkSaveRestoreResult(corrID, "", "", "no file storage available")
		return
	}
	subdirRel := filepath.Join(arkSavedArksContainerRoot, subdir)
	subdirAbs, err := safeJoinHost(rec.DataPath, subdirRel)
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
		return
	}
	activeAbs := filepath.Join(subdirAbs, activeName)
	asideAbs := filepath.Join(subdirAbs, asideName)
	targetAbs := filepath.Join(subdirAbs, target)

	if _, err := os.Stat(targetAbs); err != nil {
		d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target))
		return
	}
	finalAside := ""
	if _, err := os.Stat(activeAbs); err == nil {
		if err := os.Rename(activeAbs, asideAbs); err != nil {
			d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
			return
		}
		finalAside = asideName
	}
	in, err := os.Open(targetAbs)
	if err != nil {
		d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
		return
	}
	defer in.Close()
	out, err := os.OpenFile(activeAbs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
	if err != nil {
		// Roll aside back so we don't leave the server with no save.
		if finalAside != "" {
			_ = os.Rename(asideAbs, activeAbs)
			finalAside = ""
		}
		d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
		return
	}
	if _, err := io.Copy(out, in); err != nil {
		_ = out.Close()
		if finalAside != "" {
			_ = os.Remove(activeAbs)
			_ = os.Rename(asideAbs, activeAbs)
			finalAside = ""
		}
		d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
		return
	}
	if err := out.Close(); err != nil {
		d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
		return
	}
	d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "")
}

func (d *Dispatcher) sendArkSaveRestoreResult(corrID, asideFilename, installedFilename, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_ArkSaveRestoreResult{
			ArkSaveRestoreResult: &panelv1.ArkSaveRestoreResult{
				AsideFilename:     asideFilename,
				InstalledFilename: installedFilename,
				Error:             errMsg,
			},
		},
	})
}
