package main

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

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

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

// File-management REST endpoints, rooted at each instance's bind-mounted
// data directory. Path-traversal is defended via filepath.Clean + prefix
// check — requested paths must canonically resolve under the instance's
// data_path (after symlink resolution we only do during write in MVP).
//
// The actual filesystem ops happen on the *agent*, not the controller,
// because the controller doesn't have a filesystem view into remote
// targets. Controller-side endpoints forward to the agent via new
// proto messages (FsList/FsRead/FsWrite/FsDelete) that ride the existing
// bidi stream and reuse the pendingRegistry for correlation.

const (
	// fsMaxReadBytes caps the read payload returned to the Config-form /
	// in-browser editor flow. Kept modest because those flows load the
	// whole file into a textarea. Bulk downloads use /files/download,
	// which shares the agent-side cap but not this one.
	fsMaxReadBytes = 4 * 1024 * 1024
	// fsMaxWriteBytes + fsMaxDownloadBytes share the same value — both
	// cover bulk-file round-trips (uploads to the instance, downloads
	// from the instance). 4 GiB lets a full Reforged Eden 2 scenario
	// archive (~1.5 GB) or a modded ARK save through without hitting
	// the cap. The body still goes through memory; if real streaming
	// becomes necessary we'll add a chunked RPC.
	fsMaxWriteBytes    = 4 * 1024 * 1024 * 1024
	fsMaxDownloadBytes = 4 * 1024 * 1024 * 1024

	// fsUploadAwait bounds how long the controller waits for the agent
	// to acknowledge a file write. Big writes through Docker's
	// CopyToContainer can take a few minutes on cold storage; ten
	// minutes is well past worst-case for a multi-GB upload.
	fsUploadAwait   = 10 * 60 * 1000 * 1000 * 1000
	fsExtractAwait  = 15 * 60 * 1000 * 1000 * 1000
	fsCompressAwait = 15 * 60 * 1000 * 1000 * 1000
	fsRenameAwait   = 30 * 1000 * 1000 * 1000

	// fsMaxChunkBytes caps a single chunk in the streaming-upload
	// protocol. Picked to stay well under any common edge-proxy body
	// cap (Cloudflare free 100 MB, Pro 200 MB) AND below the gRPC
	// per-message limit. 32 MiB is a good throughput compromise:
	// big enough to amortize round-trip latency, small enough that
	// memory pressure stays trivial.
	fsMaxChunkBytes = 32 * 1024 * 1024
)

// lookupInstance resolves an instance_id to its agent_id via DB; fails
// cleanly if the instance isn't persisted.
func (h *httpServer) lookupInstance(r *http.Request, id string) (agentID string, err error) {
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		return "", err
	}
	for _, row := range rows {
		if row.InstanceID == id {
			return row.AgentID, nil
		}
	}
	return "", fmt.Errorf("instance %q not in db", id)
}

// safePath joins rel under root and rejects anything that would escape.
// Returns the absolute, cleaned result.
func safePath(root, rel string) (string, error) {
	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
	}
	if !strings.HasPrefix(absJoined+string(filepath.Separator), absRoot+string(filepath.Separator)) &&
		absJoined != absRoot {
		return "", fmt.Errorf("path escapes instance data directory")
	}
	return absJoined, nil
}

// --- HTTP handlers: GET /api/instances/{id}/files?path=... etc. ---

func (h *httpServer) filesList(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	corrID := newCorrelationID("fslist")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsList{
			FsList: &panelv1.FsListRequest{
				InstanceId: id,
				Path:       r.URL.Query().Get("path"),
			},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 10*1000*1000*1000)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsListResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_list_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	entries := make([]map[string]any, 0, len(res.Entries))
	for _, e := range res.Entries {
		entries = append(entries, map[string]any{
			"name":  e.Name,
			"path":  e.Path,
			"is_dir": e.IsDir,
			"size":  e.Size,
			"mod_time": e.ModTime.AsTime().Format("2006-01-02T15:04:05Z07:00"),
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"entries": entries, "path": res.Path})
}

func (h *httpServer) filesRead(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	corrID := newCorrelationID("fsread")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsRead{
			FsRead: &panelv1.FsReadRequest{InstanceId: id, Path: r.URL.Query().Get("path")},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 15*1000*1000*1000)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsReadResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_read_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"path":    res.Path,
		"content": string(res.Content),
		"size":    len(res.Content),
	})
}

type fsWriteReq struct {
	Path    string `json:"path"`
	Content string `json:"content"`
}

func (h *httpServer) filesWrite(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, fsMaxWriteBytes+1))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if len(body) > fsMaxWriteBytes {
		writeError(w, http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("body > %d bytes", fsMaxWriteBytes))
		return
	}
	var req fsWriteReq
	if err := json.Unmarshal(body, &req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.Path == "" {
		writeError(w, http.StatusBadRequest, "missing_path", "path is required")
		return
	}
	corrID := newCorrelationID("fswrite")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsWrite{
			FsWrite: &panelv1.FsWriteRequest{
				InstanceId: id,
				Path:       req.Path,
				Content:    []byte(req.Content),
			},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 15*1000*1000*1000)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsWriteResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_write_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"bytes": res.BytesWritten})
}

// filesDownload streams a file from the instance's volumes as an
// attachment, so the browser triggers a file-save dialog. Uses the same
// FsRead RPC as the editor but bypasses the JSON wrapper (raw bytes
// out) + sets Content-Disposition so filenames land sensibly.
func (h *httpServer) filesDownload(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	p := r.URL.Query().Get("path")
	if p == "" {
		writeError(w, http.StatusBadRequest, "missing_path", "path query parameter is required")
		return
	}
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	corrID := newCorrelationID("fsdl")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsRead{
			FsRead: &panelv1.FsReadRequest{InstanceId: id, Path: p},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 60*1000*1000*1000)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsReadResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_read_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	if len(res.Content) > fsMaxDownloadBytes {
		writeError(w, http.StatusRequestEntityTooLarge, "too_large",
			fmt.Sprintf("file is %d bytes, panel download cap is %d — use SFTP or backups for larger files", len(res.Content), fsMaxDownloadBytes))
		return
	}
	name := filepath.Base(p)
	if name == "" || name == "." || name == "/" {
		name = id + "-file"
	}
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Header().Set("Content-Length", fmt.Sprintf("%d", len(res.Content)))
	// RFC 6266 encoded filename — keeps non-ASCII names (and spaces)
	// intact across browsers.
	w.Header().Set("Content-Disposition",
		fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`,
			sanitizeAttachmentName(name), urlEscape(name)))
	_, _ = w.Write(res.Content)
}

// filesUpload accepts a raw-body PUT/POST of file bytes and forwards it
// through the FsWrite RPC. Query params: path (destination, required).
// Multipart is rejected — frontend sends raw bytes with Content-Type
// application/octet-stream, simpler than parsing form fields.
func (h *httpServer) filesUpload(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	p := r.URL.Query().Get("path")
	if p == "" {
		writeError(w, http.StatusBadRequest, "missing_path", "path query parameter is required")
		return
	}
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, fsMaxWriteBytes+1))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if len(body) > fsMaxWriteBytes {
		writeError(w, http.StatusRequestEntityTooLarge, "too_large",
			fmt.Sprintf("upload is larger than %d bytes", fsMaxWriteBytes))
		return
	}
	corrID := newCorrelationID("fsupload")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsWrite{
			FsWrite: &panelv1.FsWriteRequest{InstanceId: id, Path: p, Content: body},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, fsUploadAwait)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsWriteResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_write_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"bytes": res.BytesWritten, "path": p})
}

// filesUploadChunk receives one chunk of a streaming upload.
//
//	Headers:
//	  X-Upload-Id   — client-generated UUID; ties chunks to a session.
//	  X-Chunk-Offset — byte offset where THIS chunk's data starts.
//	  X-Total-Size  — total file size (sent on every chunk; agent uses
//	                  it to validate the final byte count).
//	  X-Is-Final    — "1" if this is the last chunk for the upload.
//	Query: path — destination path (same as single-shot upload).
//	Body: raw chunk bytes (recommended 8–32 MiB).
//
// Each chunk is forwarded to the agent as one FsWriteChunk RPC. The
// chunk size is capped at fsMaxChunkBytes; clients sending larger
// chunks get 413 so they can fall back. The whole point of this
// endpoint is that any single HTTP request stays well under typical
// edge-proxy body caps (Cloudflare 100 MB on free, etc.).
func (h *httpServer) filesUploadChunk(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	p := r.URL.Query().Get("path")
	if p == "" {
		writeError(w, http.StatusBadRequest, "missing_path", "path query parameter is required")
		return
	}
	uploadID := r.Header.Get("X-Upload-Id")
	if uploadID == "" {
		writeError(w, http.StatusBadRequest, "missing_upload_id", "X-Upload-Id header is required")
		return
	}
	offsetHdr := r.Header.Get("X-Chunk-Offset")
	totalHdr := r.Header.Get("X-Total-Size")
	finalHdr := r.Header.Get("X-Is-Final")
	var offset, total int64
	if offsetHdr != "" {
		_, _ = fmt.Sscanf(offsetHdr, "%d", &offset)
	}
	if totalHdr != "" {
		_, _ = fmt.Sscanf(totalHdr, "%d", &total)
	}
	isFinal := finalHdr == "1" || finalHdr == "true"

	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, fsMaxChunkBytes+1))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if int64(len(body)) > fsMaxChunkBytes {
		writeError(w, http.StatusRequestEntityTooLarge, "chunk_too_large",
			fmt.Sprintf("chunk is larger than %d bytes; reduce client chunk size", fsMaxChunkBytes))
		return
	}
	h.log.Info("fs chunk arrived",
		"instance_id", id, "agent_id", agentID, "upload_id", uploadID,
		"path", p, "offset", offset, "size", len(body),
		"total", total, "is_final", isFinal, "remote", r.RemoteAddr)
	corrID := newCorrelationID("fschunk")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsWriteChunk{
			FsWriteChunk: &panelv1.FsWriteChunkRequest{
				InstanceId: id,
				UploadId:   uploadID,
				Path:       p,
				Offset:     offset,
				Data:       body,
				TotalSize:  total,
				IsFinal:    isFinal,
			},
		},
	}); err != nil {
		h.log.Warn("fs chunk forward failed", "upload_id", uploadID, "err", err)
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	// Per-chunk awaits are short — a chunk write is just disk append.
	// On the FINAL chunk the agent does CopyToContainer which can take
	// a couple of minutes for a multi-GB temp file, so use the upload
	// await for that one.
	var wait time.Duration = 60 * time.Second
	if isFinal {
		wait = fsUploadAwait
	}
	env, err := h.pending.Await(r.Context(), corrID, wait)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsWriteChunkResult()
	if res == nil {
		h.log.Warn("fs chunk: agent reply missing payload", "upload_id", uploadID)
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_write_chunk_result")
		return
	}
	if res.Error != "" {
		h.log.Warn("fs chunk: agent error", "upload_id", uploadID, "err", res.Error)
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	h.log.Info("fs chunk acked",
		"upload_id", uploadID, "bytes", res.BytesReceived,
		"total", res.TotalReceived, "finalized", res.Finalized)
	writeJSON(w, http.StatusOK, map[string]any{
		"bytes_received": res.BytesReceived,
		"total_received": res.TotalReceived,
		"finalized":      res.Finalized,
	})
}

// filesExtract expands an archive that already lives in the volume.
// Body: { "archive_path": "...", "dest_dir": "..." (optional), "overwrite": bool }
func (h *httpServer) filesExtract(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req struct {
		ArchivePath string `json:"archive_path"`
		DestDir     string `json:"dest_dir"`
		Overwrite   bool   `json:"overwrite"`
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if err := json.Unmarshal(body, &req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.ArchivePath == "" {
		writeError(w, http.StatusBadRequest, "missing_archive_path", "archive_path is required")
		return
	}
	corrID := newCorrelationID("fsextract")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsExtract{
			FsExtract: &panelv1.FsExtractRequest{
				InstanceId:  id,
				ArchivePath: req.ArchivePath,
				DestDir:     req.DestDir,
				Overwrite:   req.Overwrite,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, fsExtractAwait)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsExtractResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_extract_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"entries_written": res.EntriesWritten,
		"bytes_written":   res.BytesWritten,
	})
}

// filesCompress builds a zip from one or more sources inside the volume.
// Body: { "sources": ["path1", "path2/sub"], "dest_zip_path": "out.zip", "overwrite": bool }
func (h *httpServer) filesCompress(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req struct {
		Sources     []string `json:"sources"`
		DestZipPath string   `json:"dest_zip_path"`
		Overwrite   bool     `json:"overwrite"`
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if err := json.Unmarshal(body, &req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if len(req.Sources) == 0 {
		writeError(w, http.StatusBadRequest, "missing_sources", "sources must contain at least one path")
		return
	}
	if req.DestZipPath == "" {
		writeError(w, http.StatusBadRequest, "missing_dest", "dest_zip_path is required")
		return
	}
	corrID := newCorrelationID("fscompress")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsCompress{
			FsCompress: &panelv1.FsCompressRequest{
				InstanceId:  id,
				Sources:     req.Sources,
				DestZipPath: req.DestZipPath,
				Overwrite:   req.Overwrite,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, fsCompressAwait)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsCompressResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_compress_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"entries_written": res.EntriesWritten,
		"bytes_written":   res.BytesWritten,
	})
}

// filesRename does a path-safe `mv`. Body: { "from_path": "...", "to_path": "..." }
func (h *httpServer) filesRename(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	var req struct {
		FromPath string `json:"from_path"`
		ToPath   string `json:"to_path"`
	}
	body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
	if err != nil {
		writeError(w, http.StatusBadRequest, "read", err.Error())
		return
	}
	if err := json.Unmarshal(body, &req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if req.FromPath == "" || req.ToPath == "" {
		writeError(w, http.StatusBadRequest, "missing_paths", "from_path and to_path are required")
		return
	}
	corrID := newCorrelationID("fsrename")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsRename{
			FsRename: &panelv1.FsRenameRequest{
				InstanceId: id,
				FromPath:   req.FromPath,
				ToPath:     req.ToPath,
			},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, fsRenameAwait)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsRenameResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_rename_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// sanitizeAttachmentName replaces characters that would break an ASCII
// Content-Disposition `filename=` header. The RFC-6266 `filename*=`
// parameter alongside carries the unicode version for modern browsers.
func sanitizeAttachmentName(s string) string {
	var b strings.Builder
	for _, r := range s {
		if r < 0x20 || r == '"' || r == '\\' {
			b.WriteRune('_')
			continue
		}
		if r > 0x7e {
			b.WriteRune('_')
			continue
		}
		b.WriteRune(r)
	}
	return b.String()
}

// urlEscape percent-encodes filename bytes for the filename*=UTF-8'' form.
func urlEscape(s string) string {
	const hex = "0123456789ABCDEF"
	var b strings.Builder
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
			c == '-' || c == '_' || c == '.' || c == '~' {
			b.WriteByte(c)
		} else {
			b.WriteByte('%')
			b.WriteByte(hex[c>>4])
			b.WriteByte(hex[c&0x0f])
		}
	}
	return b.String()
}

func (h *httpServer) filesDelete(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
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
		return
	}
	corrID := newCorrelationID("fsdel")
	err = conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsDelete{
			FsDelete: &panelv1.FsDeleteRequest{
				InstanceId: id,
				Path:       r.URL.Query().Get("path"),
				// ?recursive=true → rm -rf on the agent. Without this,
				// the agent rejects directory deletes with "is a directory".
				Recursive: r.URL.Query().Get("recursive") == "true",
			},
		},
	})
	if err != nil {
		writeError(w, http.StatusBadGateway, "forward", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 10*1000*1000*1000)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsDeleteResult()
	if res == nil {
		writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing fs_delete_result")
		return
	}
	if res.Error != "" {
		writeError(w, http.StatusBadRequest, "fs", res.Error)
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// Unused-import suppressor so stdlib helpers referenced elsewhere stay imported.
var (
	_ = bytes.NewReader
	_ = os.ReadDir
	_ = sort.Strings
	_ = errors.Is
)
