package dispatch

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path"
	"path/filepath"
	"strconv"
	"strings"
	"time"

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

	agentmodule "github.com/dbledeez/panel/agent/internal/module"
	"github.com/dbledeez/panel/agent/internal/runtime"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// File management: operations are rooted at each instance's declared
// BrowseableRoot inside the container when the container is running +
// the runtime supports container-path ops (Docker). Otherwise we fall
// back to the host-side DataPath bind mount. Either path is safe-joined
// to prevent escape.

const (
	// fsMaxReadBytes caps a single file-read RPC. Big enough for full
	// world saves and most ARK/Empyrion scenario zips (a few-hundred MB
	// is common). True streaming would remove the cap entirely; until
	// then 4 GiB matches the controller-side upload cap.
	fsMaxReadBytes = 4 * 1024 * 1024 * 1024
	fsMaxListSize  = 5000

	// fsCopyTimeout bounds a single CopyToContainer / CopyFromContainer
	// call. A multi-GB upload over the loopback Docker socket can take
	// 30+ seconds, and an extract that ships the whole archive's
	// contents back to the container can be larger still — give it
	// real headroom so we don't surface spurious timeouts.
	fsCopyTimeout = 10 * time.Minute
)

// ---- Path safety ----

// safeJoinHost joins rel onto root using OS path semantics, rejecting
// anything that canonicalizes outside of root.
func safeJoinHost(root, rel string) (string, error) {
	rel = strings.TrimPrefix(rel, "/")
	rel = strings.TrimPrefix(rel, "\\")
	if rel == "" {
		return root, nil
	}
	joined := filepath.Join(root, rel)
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return "", err
	}
	absJoined, err := filepath.Abs(joined)
	if err != nil {
		return "", err
	}
	sep := string(filepath.Separator)
	if absJoined != absRoot && !strings.HasPrefix(absJoined+sep, absRoot+sep) {
		return "", fmt.Errorf("path escapes instance data directory")
	}
	return absJoined, nil
}

// safeJoinContainer joins rel onto root using Linux container semantics
// (always forward-slash). Returns the cleaned absolute path.
func safeJoinContainer(root, rel string) (string, error) {
	rel = strings.TrimPrefix(rel, "/")
	if rel == "" {
		return root, nil
	}
	joined := path.Clean(path.Join(root, rel))
	if joined != root && !strings.HasPrefix(joined+"/", root+"/") {
		return "", fmt.Errorf("path escapes instance root")
	}
	return joined, nil
}

// rootPaths returns the full list of allowed root paths for a record
// (flattens BrowseableRoots → container paths, or falls back to the
// singular BrowseableRoot). Always returns at least one entry.
func rootPaths(rec *instanceRecord) []string {
	if len(rec.BrowseableRoots) > 0 {
		out := make([]string, 0, len(rec.BrowseableRoots))
		for _, r := range rec.BrowseableRoots {
			if r.Path != "" {
				out = append(out, r.Path)
			}
		}
		if len(out) > 0 {
			return out
		}
	}
	if rec.BrowseableRoot != "" {
		return []string{rec.BrowseableRoot}
	}
	return nil
}

// safeJoinAny resolves a caller-supplied path against ANY of the module's
// declared roots. Semantics:
//
//   - If p starts with "/", it must be EQUAL TO or UNDER one of the
//     roots; we return it cleaned as-is. Lets the UI send container-
//     absolute paths when it knows the full layout (multi-root modules).
//   - Otherwise p is relative, resolved against `defaultRoot`. Same
//     safeJoinContainer behaviour as before.
//
// Returns an error if the path can't be mapped to any root or tries to
// escape via `..`.
func safeJoinAny(defaultRoot string, roots []string, p string) (string, error) {
	if strings.HasPrefix(p, "/") {
		cleaned := path.Clean(p)
		for _, r := range roots {
			if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
				return cleaned, nil
			}
		}
		return "", fmt.Errorf("path %q is not under any declared root", cleaned)
	}
	return safeJoinContainer(defaultRoot, p)
}

func (d *Dispatcher) lookupRecord(instanceID string) (*instanceRecord, error) {
	d.mu.Lock()
	defer d.mu.Unlock()
	rec, ok := d.instances[instanceID]
	if !ok {
		return nil, fmt.Errorf("instance %q not on this target", instanceID)
	}
	return rec, nil
}

// useContainerOps decides whether to go through the container runtime
// (true) or fall back to the host bind mount (false). Any module that
// declares a browseable root uses container ops — if the main container
// isn't running at the moment, we spin up a helper sidecar (see
// getFsTargetContainerID). This lets operators browse/edit files on
// stopped instances.
func (d *Dispatcher) useContainerOps(rec *instanceRecord) bool {
	return rec.BrowseableRoot != ""
}

// ---- FS helper sidecar: file ops on stopped containers ----

// fsHelperImage is the base for the helper container. We use debian-slim
// rather than alpine because our `ls --time-style=+%s` call relies on GNU
// coreutils — busybox's ls doesn't accept --time-style. debian:12-slim is
// already on disk for every panel-native game module, so no extra pull.
const fsHelperImage = "debian:12-slim"

// fsHelperIdleTTL is how long a helper container is kept alive after its
// last file-op access. Balance: long enough that click-around browsing
// reuses the same helper without respawning; short enough that a forgotten
// Files tab doesn't pin volumes forever.
const fsHelperIdleTTL = 5 * time.Minute

// getFsTargetContainerID picks the container to route a file op through:
// the main instance container if it's running, or a helper sidecar
// (lazy-created + started on demand) if it isn't. The helper reuses the
// same named volumes as the main container, so the operator sees the
// same files they would see with the game running.
func (d *Dispatcher) getFsTargetContainerID(ctx context.Context, rec *instanceRecord) (string, error) {
	mainName := "panel-" + rec.InstanceID
	st, err := d.runtime.InspectByName(ctx, mainName)
	if err == nil && st.Status == "running" {
		// Main is alive — tear down any lingering helper so volumes
		// aren't held by two containers simultaneously during live play.
		d.teardownFsHelper(rec.InstanceID)
		return st.ContainerID, nil
	}
	return d.ensureFsHelper(ctx, rec)
}

func (d *Dispatcher) ensureFsHelper(ctx context.Context, rec *instanceRecord) (string, error) {
	helperName := "panel-" + rec.InstanceID + "-fs"

	// Reuse existing helper if it's still healthy.
	d.mu.Lock()
	h, ok := d.fsHelpers[rec.InstanceID]
	d.mu.Unlock()
	if ok {
		st, err := d.runtime.InspectByName(ctx, helperName)
		if err == nil && st.Status == "running" {
			d.mu.Lock()
			h.lastAccess = time.Now()
			d.mu.Unlock()
			return h.containerID, nil
		}
		// Stale record — container gone or in a bad state. Drop + rebuild.
		d.teardownFsHelper(rec.InstanceID)
	}

	// Check for an orphaned helper container on Docker's side from a
	// previous agent run. When the agent restarts, in-memory fsHelpers
	// empties but the container persists. Adopt it if it's running,
	// remove it if not (Create will then succeed).
	if st, err := d.runtime.InspectByName(ctx, helperName); err == nil {
		if st.Status == "running" {
			d.log.Info("fs helper adopted from prior agent run",
				"instance_id", rec.InstanceID, "container_id", st.ContainerID)
			d.mu.Lock()
			d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: st.ContainerID, lastAccess: time.Now()}
			d.mu.Unlock()
			return st.ContainerID, nil
		}
		d.log.Info("removing stale fs helper before recreate",
			"instance_id", rec.InstanceID, "container_id", st.ContainerID, "status", st.Status)
		rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
		_ = d.runtime.Remove(rmCtx, st.ContainerID)
		rmCancel()
	}

	// Resolve the instance's volumes from its manifest so the helper sees
	// exactly what the game would.
	manifest, ok := d.modules.Get(rec.ModuleID)
	if !ok {
		return "", fmt.Errorf("module %q not loaded; can't mount helper volumes", rec.ModuleID)
	}
	volumes := agentmodule.ResolveVolumes(manifest, rec.InstanceID, rec.DataPath)

	spec := runtime.InstanceSpec{
		InstanceID:    rec.InstanceID + "-fs",
		IsSidecar:     true,
		Image:         fsHelperImage,
		Command:       []string{"sleep", "infinity"},
		Volumes:       volumes,
		RestartPolicy: "no",
	}
	containerID, err := d.runtime.Create(ctx, spec)
	if err != nil {
		return "", fmt.Errorf("create fs helper: %w", err)
	}
	if err := d.runtime.Start(ctx, containerID); err != nil {
		// Clean up the half-created container so next call doesn't see a
		// zombie from `Created` state.
		rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer rmCancel()
		_ = d.runtime.Remove(rmCtx, containerID)
		return "", fmt.Errorf("start fs helper: %w", err)
	}
	d.mu.Lock()
	d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: containerID, lastAccess: time.Now()}
	d.mu.Unlock()
	d.log.Info("fs helper started", "instance_id", rec.InstanceID, "container_id", containerID)
	return containerID, nil
}

// teardownFsHelper stops and removes the helper sidecar for an instance,
// if one exists. Safe to call at any time — no-ops on absent helpers.
// Called on main-container start (to free volumes) and on instance
// delete (cleanup).
func (d *Dispatcher) teardownFsHelper(instanceID string) {
	d.mu.Lock()
	h, ok := d.fsHelpers[instanceID]
	if ok {
		delete(d.fsHelpers, instanceID)
	}
	d.mu.Unlock()
	if !ok {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = d.runtime.Stop(ctx, h.containerID, 2*time.Second)
	_ = d.runtime.Remove(ctx, h.containerID)
	d.log.Info("fs helper torn down", "instance_id", instanceID)
}

// fsHelperReaper runs for the lifetime of the dispatcher and tears down
// any helper that hasn't been touched in fsHelperIdleTTL. Scans every
// minute — granular enough that abandoned Files tabs don't pin resources
// for long, coarse enough that the scan itself is cheap.
func (d *Dispatcher) fsHelperReaper() {
	ticker := time.NewTicker(1 * time.Minute)
	defer ticker.Stop()
	for range ticker.C {
		now := time.Now()
		d.mu.Lock()
		stale := make([]string, 0)
		for id, h := range d.fsHelpers {
			if now.Sub(h.lastAccess) > fsHelperIdleTTL {
				stale = append(stale, id)
			}
		}
		d.mu.Unlock()
		for _, id := range stale {
			d.teardownFsHelper(id)
		}
	}
}

// ---- FsList ----

func (d *Dispatcher) handleFsList(corrID string, req *panelv1.FsListRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsListResult(corrID, nil, "", err.Error())
		return
	}
	if d.useContainerOps(rec) {
		entries, err := d.listInContainer(rec, req.Path)
		if err != nil {
			d.sendFsListResult(corrID, nil, req.Path, err.Error())
			return
		}
		d.sendFsListResult(corrID, entries, req.Path, "")
		return
	}
	// Host fallback — bind mount.
	if rec.DataPath == "" {
		d.sendFsListResult(corrID, nil, req.Path, "no file storage available (container not running and no host data path)")
		return
	}
	absPath, err := safeJoinHost(rec.DataPath, req.Path)
	if err != nil {
		d.sendFsListResult(corrID, nil, req.Path, err.Error())
		return
	}
	entries, err := os.ReadDir(absPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			d.sendFsListResult(corrID, nil, req.Path, "not found")
			return
		}
		d.sendFsListResult(corrID, nil, req.Path, err.Error())
		return
	}
	out := make([]*panelv1.FsEntry, 0, len(entries))
	for i, e := range entries {
		if i >= fsMaxListSize {
			break
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		out = append(out, &panelv1.FsEntry{
			Name:    e.Name(),
			Path:    filepath.ToSlash(filepath.Join(req.Path, e.Name())),
			IsDir:   e.IsDir(),
			Size:    info.Size(),
			ModTime: timestamppb.New(info.ModTime()),
		})
	}
	d.sendFsListResult(corrID, out, filepath.ToSlash(req.Path), "")
}

// listInContainer shells out to `ls -la --time-style=+%s <root/rel>` and
// parses the output into FsEntry records.
func (d *Dispatcher) listInContainer(rec *instanceRecord, rel string) ([]*panelv1.FsEntry, error) {
	abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), rel)
	if err != nil {
		return nil, err
	}
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	targetID, err := d.getFsTargetContainerID(ctx, rec)
	if err != nil {
		return nil, err
	}
	// -L dereferences symlinks so that listing a `@ModName` link (which
	// points into the shared workshop tree) shows the mod's contents, not
	// the symlink itself. Without this, `ls -l` on a symlinked dir prints
	// the symlink as a single entry and the UI looks empty.
	stdout, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{
		"sh", "-c",
		"ls -lAL --time-style=+%s -- " + shellQuote(abs),
	})
	if err != nil {
		return nil, fmt.Errorf("exec ls: %w", err)
	}
	if code != 0 {
		msg := strings.TrimSpace(string(stderr))
		if msg == "" {
			msg = fmt.Sprintf("ls exited %d", code)
		}
		return nil, fmt.Errorf("%s", msg)
	}
	return parseLsOutput(string(stdout), rel)
}

// parseLsOutput parses `ls -lA --time-style=+%s` output. Each line:
//
//	drwxr-xr-x  2 root root   4096 1735000000 somefile
//
// We split on any whitespace and take fields. The date field is a unix
// timestamp (because of --time-style=+%s).
func parseLsOutput(out, relRoot string) ([]*panelv1.FsEntry, error) {
	lines := strings.Split(strings.TrimSpace(out), "\n")
	entries := make([]*panelv1.FsEntry, 0, len(lines))
	for i, line := range lines {
		if i == 0 && strings.HasPrefix(line, "total ") {
			continue
		}
		// Split into at most 7 fields so filenames with spaces survive.
		fields := strings.Fields(line)
		if len(fields) < 7 {
			continue
		}
		// fields[0]=mode 1=nlink 2=user 3=group 4=size 5=unixts 6+=name
		size, _ := strconv.ParseInt(fields[4], 10, 64)
		tsUnix, _ := strconv.ParseInt(fields[5], 10, 64)
		name := strings.Join(fields[6:], " ")
		// For symlinks "name -> target", keep just the name.
		if strings.Contains(name, " -> ") {
			name = strings.SplitN(name, " -> ", 2)[0]
		}
		if name == "" || name == "." || name == ".." {
			continue
		}
		isDir := strings.HasPrefix(fields[0], "d")
		entries = append(entries, &panelv1.FsEntry{
			Name:    name,
			Path:    path.Join(relRoot, name),
			IsDir:   isDir,
			Size:    size,
			ModTime: timestamppb.New(time.Unix(tsUnix, 0)),
		})
	}
	return entries, nil
}

// shellQuote wraps s in single quotes, escaping embedded quotes for
// interpolation into `sh -c`.
func shellQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

func (d *Dispatcher) sendFsListResult(corrID string, entries []*panelv1.FsEntry, p, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsListResult{
			FsListResult: &panelv1.FsListResult{Entries: entries, Path: p, Error: errMsg},
		},
	})
}

// ---- FsRead ----

func (d *Dispatcher) handleFsRead(corrID string, req *panelv1.FsReadRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsReadResult(corrID, req.Path, nil, err.Error())
		return
	}
	if d.useContainerOps(rec) {
		abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
		if err != nil {
			d.sendFsReadResult(corrID, req.Path, nil, err.Error())
			return
		}
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
		defer cancel()
		targetID, err := d.getFsTargetContainerID(ctx, rec)
		if err != nil {
			d.sendFsReadResult(corrID, req.Path, nil, err.Error())
			return
		}
		data, err := d.runtime.CopyFileFromContainer(ctx, targetID, abs)
		if err != nil {
			d.sendFsReadResult(corrID, req.Path, nil, err.Error())
			return
		}
		if len(data) > fsMaxReadBytes {
			d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", len(data), fsMaxReadBytes))
			return
		}
		d.sendFsReadResult(corrID, req.Path, data, "")
		return
	}
	// Host fallback.
	if rec.DataPath == "" {
		d.sendFsReadResult(corrID, req.Path, nil, "no file storage available")
		return
	}
	abs, err := safeJoinHost(rec.DataPath, req.Path)
	if err != nil {
		d.sendFsReadResult(corrID, req.Path, nil, err.Error())
		return
	}
	info, err := os.Stat(abs)
	if err != nil {
		d.sendFsReadResult(corrID, req.Path, nil, err.Error())
		return
	}
	if info.IsDir() {
		d.sendFsReadResult(corrID, req.Path, nil, "is a directory")
		return
	}
	if info.Size() > fsMaxReadBytes {
		d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", info.Size(), fsMaxReadBytes))
		return
	}
	data, err := os.ReadFile(abs)
	if err != nil {
		d.sendFsReadResult(corrID, req.Path, nil, err.Error())
		return
	}
	d.sendFsReadResult(corrID, req.Path, data, "")
}

func (d *Dispatcher) sendFsReadResult(corrID, p string, content []byte, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsReadResult{
			FsReadResult: &panelv1.FsReadResult{Path: p, Content: content, Error: errMsg},
		},
	})
}

// ---- FsWrite ----

func (d *Dispatcher) handleFsWrite(corrID string, req *panelv1.FsWriteRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsWriteResult(corrID, 0, err.Error())
		return
	}
	if d.useContainerOps(rec) {
		abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
		if err != nil {
			d.sendFsWriteResult(corrID, 0, err.Error())
			return
		}
		// Make sure the parent dir exists. `mkdir -p` inside container.
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
		targetID, terr := d.getFsTargetContainerID(ctx, rec)
		if terr != nil {
			cancel()
			d.sendFsWriteResult(corrID, 0, terr.Error())
			return
		}
		_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			"mkdir -p -- " + shellQuote(path.Dir(abs))})
		cancel()
		ctx2, cancel2 := context.WithTimeout(context.Background(), fsCopyTimeout)
		defer cancel2()
		if err := d.runtime.CopyFileToContainer(ctx2, targetID, abs, req.Content); err != nil {
			d.sendFsWriteResult(corrID, 0, err.Error())
			return
		}
		d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
		return
	}
	// Host fallback.
	if rec.DataPath == "" {
		d.sendFsWriteResult(corrID, 0, "no file storage available")
		return
	}
	abs, err := safeJoinHost(rec.DataPath, req.Path)
	if err != nil {
		d.sendFsWriteResult(corrID, 0, err.Error())
		return
	}
	if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
		d.sendFsWriteResult(corrID, 0, err.Error())
		return
	}
	if err := os.WriteFile(abs, req.Content, 0o644); err != nil {
		d.sendFsWriteResult(corrID, 0, err.Error())
		return
	}
	d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
}

func (d *Dispatcher) sendFsWriteResult(corrID string, bytesWritten int64, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_FsWriteResult{
			FsWriteResult: &panelv1.FsWriteResult{BytesWritten: bytesWritten, Error: errMsg},
		},
	})
}

// ---- FsDelete ----

func (d *Dispatcher) handleFsDelete(corrID string, req *panelv1.FsDeleteRequest) {
	rec, err := d.lookupRecord(req.InstanceId)
	if err != nil {
		d.sendFsDeleteResult(corrID, err.Error())
		return
	}
	if d.useContainerOps(rec) {
		abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
		if err != nil {
			d.sendFsDeleteResult(corrID, err.Error())
			return
		}
		if abs == rec.BrowseableRoot {
			d.sendFsDeleteResult(corrID, "refusing to delete instance root")
			return
		}
		ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
		defer cancel()
		targetID, err := d.getFsTargetContainerID(ctx, rec)
		if err != nil {
			d.sendFsDeleteResult(corrID, err.Error())
			return
		}
		flag := ""
		if req.Recursive {
			flag = "-rf"
		} else {
			flag = "-f"
		}
		_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
			"rm " + flag + " -- " + shellQuote(abs)})
		if err != nil {
			d.sendFsDeleteResult(corrID, err.Error())
			return
		}
		if code != 0 {
			d.sendFsDeleteResult(corrID, strings.TrimSpace(string(stderr)))
			return
		}
		d.sendFsDeleteResult(corrID, "")
		return
	}
	// Host fallback.
	if rec.DataPath == "" {
		d.sendFsDeleteResult(corrID, "no file storage available")
		return
	}
	abs, err := safeJoinHost(rec.DataPath, req.Path)
	if err != nil {
		d.sendFsDeleteResult(corrID, err.Error())
		return
	}
	if abs == rec.DataPath {
		d.sendFsDeleteResult(corrID, "refusing to delete instance data root")
		return
	}
	var delErr error
	if req.Recursive {
		delErr = os.RemoveAll(abs)
	} else {
		delErr = os.Remove(abs)
	}
	if delErr != nil {
		d.sendFsDeleteResult(corrID, delErr.Error())
		return
	}
	d.sendFsDeleteResult(corrID, "")
}

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