package dispatch

// DayZ mod install/uninstall + FsSymlink handlers. The controller drives
// the high-level workflow (SteamCMD workshop_download runs controller-side,
// writing to the shared panel-dayz-workshop volume); the agent's job is to
// wire the downloaded content into the instance's /game tree:
//
//   1. Create /game/<folder_name> as a symlink to
//      /game/steamapps/workshop/content/221100/<workshop_id>
//   2. Copy every .bikey from that mod's keys/ (or Keys/) subfolder into
//      /game/keys/ so BattlEye signs mod content on boot.
//   3. On uninstall, remove the symlink + every bikey whose content hash
//      matches a bikey in the workshop mod's keys dir (so we don't remove
//      keys that belong to OTHER installed mods of the same vendor).

import (
	"context"
	"fmt"
	"path"
	"strings"
	"time"

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

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

// ---- FsSymlink ----
//
// The symlink is created inside the instance's container namespace so that
// any `/game/@Mod` symlink resolves correctly from the DayZServer binary's
// perspective.

func (d *Dispatcher) handleFsSymlink(corrID string, req *panelv1.FsSymlinkRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsSymlinkResult(corrID, err.Error())
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendFsSymlinkResult(corrID, err.Error())
		return
	}
	// `ln -sfn` replaces an existing symlink atomically. We require
	// abs paths for both target and link_path and refuse anything outside
	// the configured browseable roots for link_path (the symlink itself;
	// target can point outside for workshop shares).
	if !strings.HasPrefix(req.LinkPath, "/") || !strings.HasPrefix(req.Target, "/") {
		d.sendFsSymlinkResult(corrID, "target and link_path must be absolute")
		return
	}
	if _, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.LinkPath); err != nil {
		d.sendFsSymlinkResult(corrID, "link_path outside browseable roots: "+err.Error())
		return
	}
	// `ln` errors with "No such file or directory" if the parent doesn't
	// exist yet — common on first mod install for modules whose entrypoint
	// hasn't lazily created the Mods/ folder yet (Conan Exiles). `mkdir -p`
	// the parent before linking so the symlink-then-game-restart flow works
	// even on a fresh recreate. Idempotent: mkdir on existing dir is a
	// no-op, and the safeJoinAny check above already proved link_path is
	// inside a declared root so we're not creating dirs in arbitrary spots.
	parentDir := path.Dir(req.LinkPath)
	_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf("mkdir -p %q && ln -sfn %q %q", parentDir, req.Target, req.LinkPath)})
	if err != nil {
		d.sendFsSymlinkResult(corrID, err.Error())
		return
	}
	if code != 0 {
		d.sendFsSymlinkResult(corrID, fmt.Sprintf("ln exited %d: %s", code, strings.TrimSpace(string(stderr))))
		return
	}
	d.sendFsSymlinkResult(corrID, "")
}

func (d *Dispatcher) sendFsSymlinkResult(corrID, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsSymlinkResult{
			FsSymlinkResult: &panelv1.FsSymlinkResult{Error: errMsg},
		},
	})
}

// ---- DayzModInstall ----

func (d *Dispatcher) handleDayzModInstall(corrID string, req *panelv1.DayzModInstallRequest) {
	d.log.Info("dayz mod install", "instance_id", req.InstanceId, "workshop_id", req.WorkshopId, "folder", req.FolderName)
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
		return
	}
	if req.WorkshopId == "" || req.FolderName == "" {
		d.sendDayzModInstallResult(corrID, "workshop_id and folder_name are required", nil, "")
		return
	}
	// Normalize folder name: must start with '@', no slashes, no whitespace.
	folder := strings.TrimSpace(req.FolderName)
	if !strings.HasPrefix(folder, "@") {
		folder = "@" + folder
	}
	if strings.ContainsAny(folder, "/\\ \t\n") {
		d.sendDayzModInstallResult(corrID, "folder_name must not contain slashes or whitespace", nil, "")
		return
	}

	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
		return
	}

	modPath := "/game/steamapps/workshop/content/221100/" + req.WorkshopId
	linkPath := "/game/" + folder

	// 1. Verify workshop content exists.
	_, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf("test -d %q", modPath)})
	if code != 0 {
		d.sendDayzModInstallResult(corrID, "workshop content not found at "+modPath+" — did SteamCMD download succeed?", nil, modPath)
		return
	}

	// 2. Create symlink.
	_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf("ln -sfn %q %q", modPath, linkPath)})
	if err != nil {
		d.sendDayzModInstallResult(corrID, "symlink: "+err.Error(), nil, modPath)
		return
	}
	if code != 0 {
		d.sendDayzModInstallResult(corrID, "symlink failed: "+strings.TrimSpace(string(stderr)), nil, modPath)
		return
	}

	// 3. Copy bikeys. DayZ mods ship them under keys/ OR Keys/ inside the
	// workshop tree. Copy every .bikey we find there into /game/keys/.
	// Keep a manifest of copied filenames so uninstall can remove them.
	// Use `find` + `cp` for portability; busybox and GNU both understand.
	stdout, stderr2, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf(`mkdir -p /game/keys && \
		 for src in %q/keys %q/Keys; do \
		     [ -d "$src" ] || continue; \
		     for f in "$src"/*.bikey "$src"/*.BIKEY; do \
		         [ -f "$f" ] || continue; \
		         base=$(basename "$f"); \
		         cp -f "$f" /game/keys/"$base"; \
		         printf '%%s\n' "$base"; \
		     done; \
		 done`, modPath, modPath)})
	if err != nil {
		d.sendDayzModInstallResult(corrID, "copy bikeys: "+err.Error(), nil, modPath)
		return
	}
	if code != 0 {
		d.sendDayzModInstallResult(corrID, "copy bikeys failed: "+strings.TrimSpace(string(stderr2)), nil, modPath)
		return
	}
	var bikeys []string
	for _, ln := range strings.Split(string(stdout), "\n") {
		ln = strings.TrimSpace(strings.TrimPrefix(ln, `\n`))
		if ln == "" {
			continue
		}
		bikeys = append(bikeys, ln)
	}
	d.sendDayzModInstallResult(corrID, "", bikeys, modPath)
}

func (d *Dispatcher) sendDayzModInstallResult(corrID, errMsg string, bikeys []string, resolvedPath string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_DayzModInstallResult{
			DayzModInstallResult: &panelv1.DayzModInstallResult{
				Error:            errMsg,
				BikeysCopied:     bikeys,
				ResolvedModPath:  resolvedPath,
			},
		},
	})
}

// ---- DayzModUninstall ----

func (d *Dispatcher) handleDayzModUninstall(corrID string, req *panelv1.DayzModUninstallRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendDayzModUninstallResult(corrID, err.Error(), nil)
		return
	}
	folder := strings.TrimSpace(req.FolderName)
	if !strings.HasPrefix(folder, "@") || strings.ContainsAny(folder, "/\\ \t\n") {
		d.sendDayzModUninstallResult(corrID, "invalid folder_name", nil)
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		d.sendDayzModUninstallResult(corrID, err.Error(), nil)
		return
	}
	linkPath := "/game/" + folder

	// Resolve the symlink to find the workshop directory.
	stdout, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf("readlink -f %q 2>/dev/null || true", linkPath)})
	modPath := strings.TrimSpace(string(stdout))
	if code != 0 || modPath == "" {
		// Symlink is already gone or broken — just drop stale /game/keys entries.
		modPath = ""
	}

	// Remove bikeys whose *name* matches a bikey in the workshop mod's keys.
	// Matching by name (not hash) is what DayZ tooling does — the server
	// looks up bikeys by exact basename, and mods typically ship unique
	// key names.
	var removed []string
	if modPath != "" {
		out, _, _, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			fmt.Sprintf(`for d in %q/keys %q/Keys; do \
			    [ -d "$d" ] || continue; \
			    for f in "$d"/*.bikey "$d"/*.BIKEY; do \
			        [ -f "$f" ] || continue; \
			        base=$(basename "$f"); \
			        if [ -f "/game/keys/$base" ]; then \
			            rm -f "/game/keys/$base"; \
			            echo "$base"; \
			        fi; \
			    done; \
			done`, modPath, modPath)})
		for _, ln := range strings.Split(string(out), "\n") {
			ln = strings.TrimSpace(ln)
			if ln == "" {
				continue
			}
			removed = append(removed, ln)
		}
	}

	// Remove the symlink itself.
	_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
		fmt.Sprintf("rm -f %q", linkPath)})
	if err != nil {
		d.sendDayzModUninstallResult(corrID, "rm symlink: "+err.Error(), removed)
		return
	}
	if code != 0 {
		d.sendDayzModUninstallResult(corrID, "rm symlink failed: "+strings.TrimSpace(string(stderr)), removed)
		return
	}
	d.sendDayzModUninstallResult(corrID, "", removed)
}

func (d *Dispatcher) sendDayzModUninstallResult(corrID, errMsg string, bikeys []string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_DayzModUninstallResult{
			DayzModUninstallResult: &panelv1.DayzModUninstallResult{
				Error:          errMsg,
				BikeysRemoved:  bikeys,
			},
		},
	})
}
