// backend/main.go
package main

import (
	"embed"
	"encoding/json"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"
	"sort"

	"github.com/go-chi/chi/v5"
	"github.com/prometheus/client_golang/prometheus/promhttp"
	"github.com/tus/tusd/pkg/filestore"
	tusd "github.com/tus/tusd/pkg/handler"
	"github.com/tus/tusd/pkg/memorylocker"

	"scm.bstein.dev/bstein/Pegasus/backend/internal"
)

//go:embed web/dist
var webFS embed.FS

var (
	mediaRoot   = env("PEGASUS_MEDIA_ROOT", "/media")
	userMapFile = env("PEGASUS_USER_MAP_FILE", "/config/user-map.yaml")
	tusDir      = env("PEGASUS_TUS_DIR", filepath.Join(mediaRoot, ".pegasus-tus"))
	jf          = internal.NewJellyfin()
)

var (
    version = "dev"   // set via -ldflags "-X main.version=1.2.0 -X main.git=$(git rev-parse --short HEAD) -X main.builtAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
    git     = ""
    builtAt = ""
)

type loggingRW struct {
	http.ResponseWriter
	status int
}

type listEntry struct {
	Name  string `json:"name"`
	Path  string `json:"path"`
	IsDir bool   `json:"is_dir"`
	Size  int64  `json:"size"`
	Mtime int64  `json:"mtime"`
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(v); err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
	}
}

func (l *loggingRW) WriteHeader(code int) { l.status = code; l.ResponseWriter.WriteHeader(code) }

func main() {
	internal.Logf("PEGASUS_DEBUG=%v, DRY_RUN=%v, TUS_DIR=%s, MEDIA_ROOT=%s", internal.Debug, internal.DryRun, tusDir, mediaRoot)

	if os.Getenv("PEGASUS_SESSION_KEY") == "" {
					log.Fatal("PEGASUS_SESSION_KEY is not set")
	}

	um, err := internal.LoadUserMap(userMapFile)
	must(err, "load user map")
	if internal.Debug {
			keys := make([]string, 0, len(um.Map))
			for k := range um.Map { keys = append(keys, k) }
			sort.Strings(keys)
			show := keys
			if len(keys) > 10 { show = keys[:10] }
			internal.Logf("user-map loaded (%d): %v%s", len(keys), show, func() string {
					if len(keys) > len(show) { return " ..." }
					return ""
			}())
	}	

	// === tusd setup (resumable uploads) ===
	store := filestore.FileStore{Path: tusDir}
	// ensure upload scratch dir exists
	if err := os.MkdirAll(tusDir, 0o2775); err != nil {
					log.Fatalf("mkdir %s: %v", tusDir, err)
	}
	locker := memorylocker.New()
	composer := tusd.NewStoreComposer()
	store.UseIn(composer)
	locker.UseIn(composer)

	// completeC := make(chan tusd.HookEvent)
	config := tusd.Config{
			BasePath:                 "/tus/",
			StoreComposer:            composer,
			NotifyCompleteUploads:    true,
			MaxSize:                  8 * 1024 * 1024 * 1024, // 8GB per file
			RespectForwardedHeaders:  true,                    // <<< important for https behind Traefik
	}
	tusHandler, err := tusd.NewUnroutedHandler(config)
	must(err, "init tus handler")
	completeC := tusHandler.CompleteUploads		

	// ---- post-finish hook: enforce naming & mapping ----
	go func() {
		for ev := range completeC {
			claims, err := claimsFromHook(ev)
			if err != nil {
				internal.Logf("tus: no session: %v", err)
				continue
			}

			// read metadata set by the UI
			meta   := ev.Upload.MetaData
			desc   := strings.TrimSpace(meta["desc"])
			date   := strings.TrimSpace(meta["date"])              // YYYY-MM-DD or empty
			subdir := strings.Trim(strings.TrimSpace(meta["subdir"]), "/")
			lib    := strings.Trim(strings.TrimSpace(meta["lib"]), "/")
			orig   := meta["filename"]
			if orig == "" { orig = "upload.bin" }

			// Decide if description is required by extension (videos only)
			ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
			isVideo := map[string]bool{
					"mp4": true, "mkv": true, "mov": true, "avi": true, "m4v": true,
					"webm": true, "mpg": true, "mpeg": true, "ts":  true, "m2ts": true,
			}[ext]
			if isVideo && desc == "" {
					log.Printf("tus: missing desc for video; rejecting")
					return
			}
			if desc == "" { desc = "upload" } // images can default

			// Resolve destination root under /media
			var libRoot string
			if lib != "" {
					lib = sanitizeSegment(lib)
					libRoot = filepath.Join(mediaRoot, lib) // explicit library
			} else {
					if rr, err := um.Resolve(claims.Username); err == nil && rr != "" {
							libRoot = filepath.Join(mediaRoot, rr) // fallback to first mapped root
					} else {
							libRoot = "" // no valid root
					}
			}

			// The library root must already exist; we only create one-level subfolders.
			if libRoot == "" {
					log.Printf("upload: library root not resolved for user %s", claims.Username)
					continue
			}
			if fi, err := os.Stat(libRoot); err != nil || !fi.IsDir() {
					log.Printf("upload: library root missing or not accessible: %s (%v)", libRoot, err)
					continue
			}

			// Optional one-level subdir under the library
			destRoot := libRoot
			if subdir != "" {
					subdir = sanitizeSegment(subdir)
					destRoot = filepath.Join(libRoot, subdir)
					if err := os.MkdirAll(destRoot, 0o2775); err != nil {
							log.Printf("mkdir %s: %v", destRoot, err)
							continue
					}
			}

			// Compose final filename and move from TUS scratch to final
			finalName := composeFinalName(date, desc, orig)
			dst := filepath.Join(destRoot, finalName)
			if err := moveFromTus(ev, dst); err != nil {
					log.Printf("move failed: %v", err)
					continue
			}

			jf.RefreshLibrary(claims.JFToken)
		}
	}()

	// === chi router ===
	r := chi.NewRouter()
	r.Use(corsForTus)

	// health/version
	r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
					w.WriteHeader(http.StatusOK)
					_, _ = w.Write([]byte("ok"))
	})
	r.Get("/version", func(w http.ResponseWriter, _ *http.Request) {
					writeJSON(w,  map[string]any{
									"version":  version,
									"git":      git,
									"built_at": builtAt,
					})
	})

	// auth
	r.Post("/api/login", func(w http.ResponseWriter, r *http.Request) {
		var f struct {
			Username string `json:"username"`
			Password string `json:"password"`
		}
		if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
			http.Error(w, "bad json", http.StatusBadRequest)
			return
		}
		res, err := jf.AuthenticateByName(f.Username, f.Password)
		if err != nil {
			http.Error(w, "invalid credentials", http.StatusUnauthorized)
			return
		}
		// ✅ ensure this username is mapped before creating a session
		if _, err := um.Resolve(f.Username); err != nil {
			internal.Logf("login ok but map missing for %q (JF name=%q)", f.Username, res.User.Name)
			http.Error(w, "no mapping", http.StatusForbidden)
			return
		}
		// ✅ store the typed login name in the session
		if err := internal.SetSession(w, f.Username, res.AccessToken); err != nil {
			http.Error(w, "session error", http.StatusInternalServerError)
			return
		}
		writeJSON(w,  map[string]any{"ok": true})
	})

	r.Post("/api/logout", func(w http.ResponseWriter, _ *http.Request) {
		internal.ClearSession(w)
		writeJSON(w,  map[string]any{"ok": true})
	})

	// whoami
	r.Get("/api/whoami", func(w http.ResponseWriter, r *http.Request) {
		cl, err := internal.CurrentUser(r)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		dr, err := um.Resolve(cl.Username)
		if err != nil {
			http.Error(w, "no mapping", http.StatusForbidden)
			return
		}
		all, _ := um.ResolveAll(cl.Username) // ignore error here since Resolve succeeded
		writeJSON(w, map[string]any{
			"username": cl.Username,
			"root":     dr,   // first root (back-compat)
			"roots":    all,  // all roots (for future UI)
		})
	})

	// list entries
	r.Get("/api/list", func(w http.ResponseWriter, r *http.Request) {
		cl, err := internal.CurrentUser(r)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		// choose library: requested (if allowed) or default
		allRoots, _ := um.ResolveAll(cl.Username)
		reqLib := sanitizeSegment(r.URL.Query().Get("lib"))
		var rootRel string
		if reqLib != "" && contains(allRoots, reqLib) {
			rootRel = reqLib
		} else {
			rootRel, err = um.Resolve(cl.Username)
			if err != nil {
				internal.Logf("list: map missing for %q", cl.Username)
				http.Error(w, "no mapping", http.StatusForbidden)
				return
			}
		}

		// one-level subdir (optional)
		q := sanitizeSegment(r.URL.Query().Get("path")) // clamp to single segment

		rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
		dirAbs := rootAbs
		if q != "" {
			if dirAbs, err = internal.SafeJoin(rootAbs, q); err != nil {
				http.Error(w, "forbidden", http.StatusForbidden)
				return
			}
		}

		ents, err := os.ReadDir(dirAbs)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		out := make([]listEntry, 0, len(ents))
		for _, d := range ents {
			info, _ := d.Info()
			var size int64
			if info != nil && !d.IsDir() {
				size = info.Size()
			}
			var mtime int64
			if info != nil {
				mtime = info.ModTime().Unix()
			}
			out = append(out, listEntry{
				Name:  d.Name(),
				Path:  filepath.Join(q, d.Name()),
				IsDir: d.IsDir(),
				Size:  size,
				Mtime: mtime,
			})
		}

		writeJSON(w, out)
	})

	// rename
	var finalNameRe = regexp.MustCompile(`^\d{4}\.\d{2}\.\d{2}\.[A-Za-z0-9_-]{1,64}(?:\.[A-Za-z0-9_-]{1,64})?\.[A-Za-z0-9]{1,8}$`)
	r.Post("/api/rename", func(w http.ResponseWriter, r *http.Request) {
		cl, err := internal.CurrentUser(r)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		var p struct {
			Lib  string `json:"lib"`
			From string `json:"from"`
			To   string `json:"to"`
		}
		if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
		}

		// enforce final name format on files (directories may be free-form one level)
		if filepath.Ext(p.To) != "" && !finalNameRe.MatchString(filepath.Base(p.To)) {
			http.Error(w, "new name must match YYYY.MM.DD.Description.ext", http.StatusBadRequest)
			return
		}

		// choose library: requested (if allowed) or default
		allRoots, _ := um.ResolveAll(cl.Username)
		lib := sanitizeSegment(p.Lib)
		var rootRel string
		if lib != "" && contains(allRoots, lib) {
			rootRel = lib
		} else {
			rootRel, _ = um.Resolve(cl.Username)
		}

		rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
		fromAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.From, "/"))
		if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
		toAbs, err := internal.SafeJoin(rootAbs, strings.TrimPrefix(p.To, "/"))
		if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }

		_ = os.MkdirAll(filepath.Dir(toAbs), 0o2775)
		if internal.DryRun {
			internal.Logf("[DRY] mv %s -> %s", fromAbs, toAbs)
		} else if err := os.Rename(fromAbs, toAbs); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError); return
		}
		jf.RefreshLibrary(cl.JFToken)
		writeJSON(w, map[string]any{"ok": true})
	})

	// delete
	r.Delete("/api/file", func(w http.ResponseWriter, r *http.Request) {
		cl, err := internal.CurrentUser(r)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		// choose library: requested (if allowed) or default
		allRoots, _ := um.ResolveAll(cl.Username)
		lib := sanitizeSegment(r.URL.Query().Get("lib"))
		var rootRel string
		if lib != "" && contains(allRoots, lib) {
			rootRel = lib
		} else {
			rootRel, _ = um.Resolve(cl.Username)
		}

		path := strings.TrimPrefix(r.URL.Query().Get("path"), "/")
		rec := r.URL.Query().Get("recursive") == "true"

		rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
		abs, err := internal.SafeJoin(rootAbs, path)
		if err != nil {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		if internal.DryRun {
			internal.Logf("[DRY] rm%s %s", map[bool]string{true: " -r", false: ""}[rec], abs)
		} else if rec {
			err = os.RemoveAll(abs)
		} else {
			err = os.Remove(abs)
		}
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		jf.RefreshLibrary(cl.JFToken)
		writeJSON(w, map[string]any{"ok": true})
	})

	// mkdir (create subdirectory under the user's mapped root)
	r.Post("/api/mkdir", func(w http.ResponseWriter, r *http.Request) {
			cl, err := internal.CurrentUser(r)
			if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized); return }

			var p struct {
					Lib  string `json:"lib"`
					Path string `json:"path"` // single-level folder name
			}
			if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
					http.Error(w, "bad json", http.StatusBadRequest); return
			}

			// choose library: requested (if allowed) or default
			allRoots, _ := um.ResolveAll(cl.Username)
			lib := sanitizeSegment(p.Lib)
			var rootRel string
			if lib != "" && contains(allRoots, lib) {
					rootRel = lib
			} else {
					rootRel, err = um.Resolve(cl.Username)
					if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }
			}

			rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
			if fi, err := os.Stat(rootAbs); err != nil || !fi.IsDir() {
					http.Error(w, "library not found", http.StatusBadRequest)
					return
			}

			seg := sanitizeSegment(p.Path) // single-level + charset + spaces→_
			if seg == "" {
					http.Error(w, "folder name is empty", http.StatusBadRequest)
					return
			}

			abs, err := internal.SafeJoin(rootAbs, seg)
			if err != nil { http.Error(w, "forbidden", http.StatusForbidden); return }

			if internal.DryRun {
					internal.Logf("[DRY] mkdir -p %s", abs)
			} else if err := os.MkdirAll(abs, 0o2775); err != nil {
					http.Error(w, err.Error(), http.StatusInternalServerError)
					return
			}
			writeJSON(w, map[string]any{"ok": true})
	})

	// mount tus (behind auth)
	r.Route("/tus", func(rt chi.Router) {
					rt.Use(sessionRequired)
					// Create upload: POST /tus/
					rt.Post("/", tusHandler.PostFile)
					// Upload resource endpoints: /tus/{id}
					rt.Head("/{id}", tusHandler.HeadFile)
					rt.Patch("/{id}", tusHandler.PatchFile)
					rt.Delete("/{id}", tusHandler.DelFile)
					rt.Get("/{id}", tusHandler.GetFile) // optional
	})
	// metrics
	r.Handle("/metrics", promhttp.Handler())

	// static app (serve embedded web/dist)
	appFS, _ := fs.Sub(webFS, "web/dist")
	static := http.FileServer(http.FS(appFS))
	r.Get("/", func(w http.ResponseWriter, r *http.Request) {
		static.ServeHTTP(w, r)
	})
	// catch-all for SPA routes, GET only
	r.Method("GET", "/*", http.StripPrefix("/", static))

	// debug endpoints (registered before server starts)
	r.Get("/debug/env", func(w http.ResponseWriter, r *http.Request) {
		if !internal.Debug {
			http.Error(w, "disabled", http.StatusForbidden)
			return
		}
		writeJSON(w,  map[string]any{
			"mediaRoot":   mediaRoot,
			"tusDir":      tusDir,
			"userMapFile": userMapFile,
		})
	})
	r.Get("/debug/write-test", func(w http.ResponseWriter, r *http.Request) {
		if !internal.Debug {
			http.Error(w, "disabled", http.StatusForbidden)
			return
		}
		cl, err := internal.CurrentUser(r)
		if err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		rootRel, err := um.Resolve(cl.Username)
		if err != nil {
			http.Error(w, "forbidden", http.StatusForbidden)
			return
		}
		rootAbs, _ := internal.SafeJoin(mediaRoot, rootRel)
		test := filepath.Join(rootAbs, fmt.Sprintf("TEST.%d.txt", time.Now().Unix()))
		if internal.DryRun {
			internal.Logf("[DRY] write %s", test)
		} else {
			_ = os.WriteFile(test, []byte("ok\n"), 0o644)
		}
		writeJSON(w,  map[string]string{"wrote": test})
	})

	r.NotFound(func(w http.ResponseWriter, r *http.Request) {
	    http.Error(w, "no route for "+r.Method+" "+r.URL.Path, http.StatusNotFound)
	})

	// ---- wrap router with verbose request logging in debug ----
	root := http.Handler(r)
	if internal.Debug {
		root = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			id := time.Now().UnixNano()
			internal.Logf(">> %d %s %s %v", id, req.Method, req.URL.Path, internal.RedactHeaders(req.Header))
			rw := &loggingRW{ResponseWriter: w, status: 200}
			start := time.Now()
			r.ServeHTTP(rw, req)
			internal.Logf("<< %d %s %s %d %s", id, req.Method, req.URL.Path, rw.status, time.Since(start))
		})
	}

	addr := env("PEGASUS_BIND", ":8080")
	log.Printf("Pegasus listening on %s", addr)
	srv := &http.Server{Addr: addr, Handler: root, ReadTimeout: 0, WriteTimeout: 0}
	log.Fatal(srv.ListenAndServe())
}

// === helpers & middleware ===
func sessionRequired(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if _, err := internal.CurrentUser(r); err != nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func corsForTus(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// CORS for tus (preflight must succeed; cookies allowed)
		if origin := r.Header.Get("Origin"); origin != "" {
						w.Header().Set("Access-Control-Allow-Origin", origin) // cannot be * when credentials=true
						w.Header().Set("Vary", "Origin")
		}
		w.Header().Set("Access-Control-Allow-Credentials", "true")
		w.Header().Set("Access-Control-Allow-Methods", "GET,POST,HEAD,PATCH,DELETE,OPTIONS")
		w.Header().Set("Access-Control-Max-Age", "86400")
		w.Header().Set("Access-Control-Allow-Headers",
			  					"Content-Type, Tus-Resumable, Upload-Length, Upload-Defer-Length, Upload-Metadata, Upload-Offset, Upload-Concat, Upload-Checksum, X-Requested-With")
		w.Header().Set("Access-Control-Expose-Headers",
									"Location, Upload-Offset, Upload-Length, Tus-Resumable, Upload-Checksum")
		if r.Method == http.MethodOptions {
						w.WriteHeader(http.StatusNoContent)
						return
		}
		next.ServeHTTP(w, r)
	})
}

func claimsFromHook(ev tusd.HookEvent) (internal.Claims, error) {
	// Parse our session cookie from incoming request headers captured by tusd
	req := ev.HTTPRequest
	if req.Header == nil {
		return internal.Claims{}, http.ErrNoCookie
	}
	// Re-create a dummy http.Request to reuse cookie parsing & jwt verification
	r := http.Request{Header: http.Header(req.Header)}
	return internal.CurrentUser(&r)
}

func env(k, def string) string  { if v := os.Getenv(k); v != "" { return v }; return def }
func must(err error, msg string) { if err != nil { log.Fatalf("%s: %v", msg, err) } }

func sanitizeSegment(s string) string {
	s = strings.TrimSpace(s)
	s = strings.ReplaceAll(s, " ", "_")
	s = strings.Trim(s, "/")
	if i := strings.IndexByte(s, '/'); i >= 0 { s = s[:i] }
	// allow only [A-Za-z0-9_.-]
	out := make([]rune, 0, len(s))
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z',
			r >= 'A' && r <= 'Z',
			r >= '0' && r <= '9',
			r == '_', r == '-', r == '.':
			out = append(out, r)
		default:
			out = append(out, '_')
		}
	}
	if len(out) > 64 { out = out[:64] }
	return string(out)
}

func contains(ss []string, v string) bool {
	for _, s := range ss {
		if s == v { return true }
	}
	return false
}

// sanitizeDescriptor keeps [A-Za-z0-9_-], converts whitespace to underscores,
// collapses runs, and limits to 64 chars. Used in final filenames.
func sanitizeDescriptor(s string) string {
    s = strings.TrimSpace(s)
    s = strings.ReplaceAll(s, " ", "_")
    if s == "" { return "upload" }
    var b []rune
    lastUnderscore := false
    for _, r := range s {
        ok := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-'
        if !ok { r = '_' }
        if r == '_' {
            if lastUnderscore { continue }
            lastUnderscore = true
        } else {
            lastUnderscore = false
        }
        b = append(b, r)
        if len(b) >= 64 { break }
    }
    if len(b) == 0 { return "upload" }
    return string(b)
}

// stemOf strips the final extension and cleans the base.
func stemOf(orig string) string {
    base := strings.TrimSuffix(orig, filepath.Ext(orig))
    s := sanitizeDescriptor(strings.ReplaceAll(base, ".", "_"))
    if s == "" { s = "file" }
    return s
}

// composeFinalName mirrors the UI: YYYY.MM.DD.Description.ext
// date is expected as YYYY-MM-DD; falls back to today if missing/bad.
func composeFinalName(date, desc, orig string) string {
    var y, m, d string
    if len(date) >= 10 && date[4] == '-' && date[7] == '-' {
        y, m, d = date[:4], date[5:7], date[8:10]
    } else {
        now := time.Now()
        y = fmt.Sprintf("%04d", now.Year())
        m = fmt.Sprintf("%02d", int(now.Month()))
        d = fmt.Sprintf("%02d", now.Day())
    }
    clean := sanitizeDescriptor(desc)
    if clean == "" { clean = "upload" }
    stem := stemOf(orig)
    ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(orig), "."))
    if ext == "" { ext = "bin" }
    return fmt.Sprintf("%s.%s.%s.%s.%s.%s", y, m, d, clean, stem, ext)
}

// moveFromTus relocates the finished upload file from the TUS scratch directory to dst.
// Works with tusd's filestore by probing common data file names.
func moveFromTus(ev tusd.HookEvent, dst string) error {
    // Likely data file names/locations used by tusd filestore across versions
    candidates := []string{
        filepath.Join(tusDir, ev.Upload.ID),
        filepath.Join(tusDir, ev.Upload.ID+".bin"),
        filepath.Join(tusDir, "data", ev.Upload.ID),
        filepath.Join(tusDir, "data", ev.Upload.ID+".bin"),
        filepath.Join(tusDir, "uploads", ev.Upload.ID),
        filepath.Join(tusDir, "uploads", ev.Upload.ID+".bin"),
    }

    var src string
    for _, p := range candidates {
        if fi, err := os.Stat(p); err == nil && fi.Mode().IsRegular() {
            src = p
            break
        }
    }
    if src == "" {
        // last resort: scan the directory for a file starting with the id
        entries, _ := os.ReadDir(tusDir)
        for _, e := range entries {
            if !e.IsDir() && strings.HasPrefix(e.Name(), ev.Upload.ID) {
                src = filepath.Join(tusDir, e.Name())
                break
            }
        }
    }
    if src == "" {
        return fmt.Errorf("tus data file not found for id %s", ev.Upload.ID)
    }

    if err := os.MkdirAll(filepath.Dir(dst), 0o2775); err != nil {
        return err
    }
    if internal.DryRun {
        internal.Logf("[DRY] mv %s -> %s", src, dst)
        return nil
    }

    // Try fast rename; if it crosses devices, fall back to copy+remove.
    if err := os.Rename(src, dst); err != nil {
        in, err2 := os.Open(src)
        if err2 != nil { return err2 }
        defer in.Close()
        out, err3 := os.Create(dst)
        if err3 != nil { return err3 }
        if _, err := io.Copy(out, in); err != nil {
            _ = out.Close()
            return err
        }
        _ = out.Close()
        _ = os.Remove(src)
    }

    // Remove sidecars if present
    _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".info"))
    _ = os.Remove(filepath.Join(tusDir, ev.Upload.ID+".json"))
    return nil
}
