package platform

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"strings"
	"sync"
)

// SaveDownload writes a received file's bytes into dir (empty → system Downloads),
// returning the absolute path written. The name comes from a transfer peer (U2,
// untrusted), so it is sanitized to a bare filename before use — no path
// components survive, so a crafted name like "../../x" cannot escape dir. On a
// name collision a " (n)" suffix is appended rather than overwriting. After a
// successful write the file is best-effort revealed in the OS file manager.
//
// If the configured dir is unwritable (deleted, no permission, read-only), the
// write is retried into the system Downloads dir before giving up — the WebView
// has no usable download fallback (WKWebView ignores blob a[download]), so a
// received file must never be silently lost to a bad setting. The returned path
// reflects where the file actually landed, so the UI can show it.
func SaveDownload(dir, name string, data []byte) (string, error) {
	if dir == "" {
		dir = DefaultDownloadDir()
	}
	path, err := writeInto(dir, name, data)
	if err == nil {
		return path, nil
	}
	if def := DefaultDownloadDir(); def != dir {
		if p, ferr := writeInto(def, name, data); ferr == nil {
			return p, nil
		}
	}
	return "", err // report the original (configured-dir) failure
}

// writeInto sanitizes the name, resolves a non-colliding path under dir, writes
// the bytes, and reveals the result. Returns the absolute path written.
func writeInto(dir, name string, data []byte) (string, error) {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", err
	}
	safe := sanitizeFileName(name)
	if safe == "" {
		safe = "cdrop-download"
	}
	target := uniquePath(dir, safe)
	if err := os.WriteFile(target, data, 0o644); err != nil {
		return "", err
	}
	revealInFileManager(target)
	return target, nil
}

// sanitizeFileName reduces a peer-supplied name to a single safe filename:
// directory components are dropped (path-traversal defense), control chars are
// removed, and characters reserved by common filesystems are replaced with "_".
func sanitizeFileName(name string) string {
	name = filepath.Base(filepath.FromSlash(name)) // strip any directory parts
	name = strings.TrimSpace(name)
	if name == "." || name == ".." {
		return ""
	}

	var b strings.Builder
	for _, r := range name {
		switch {
		case r < 0x20:
			continue // control characters
		case strings.ContainsRune(`/\:*?"<>|`, r):
			b.WriteRune('_') // filesystem-reserved
		default:
			b.WriteRune(r)
		}
	}
	out := strings.TrimSpace(b.String())
	if len(out) > 200 {
		out = strings.TrimSpace(out[:200])
	}
	return out
}

// uniquePath returns dir/name, or dir/"name (n).ext" with the lowest n that does
// not yet exist, so a repeated transfer never silently overwrites a prior file.
func uniquePath(dir, name string) string {
	target := filepath.Join(dir, name)
	if _, err := os.Stat(target); os.IsNotExist(err) {
		return target
	}
	ext := filepath.Ext(name)
	stem := strings.TrimSuffix(name, ext)
	for i := 1; ; i += 1 {
		cand := filepath.Join(dir, fmt.Sprintf("%s (%d)%s", stem, i, ext))
		if _, err := os.Stat(cand); os.IsNotExist(err) {
			return cand
		}
	}
}

// revealInFileManager opens the OS file manager with the saved file selected.
// Best-effort: the save already succeeded, so any error here is ignored.
// CDROP_NO_REVEAL skips the side effect — for headless / CI / test runs that
// shouldn't pop a Finder/Explorer window.
func revealInFileManager(path string) {
	if os.Getenv("CDROP_NO_REVEAL") != "" {
		return
	}
	switch runtime.GOOS {
	case "darwin":
		_ = exec.Command("open", "-R", path).Start()
	case "windows":
		_ = exec.Command("explorer", "/select,"+path).Start()
	}
}

// Streaming downloads — the receiver's hybrid sink (web/.../incomingSink.ts) keeps
// a file in memory until it crosses a cap (default 256 MB), then spills to disk
// through this manager instead of carrying the whole thing in the WebView heap. We
// can't use SaveDownload for that: it takes the complete bytes at once. The reason
// the whole hybrid exists is the wails:// scheme has no persistent WebView storage,
// so OPFS degrades and starves the receive loop (the PWA→Mac ~15 MB deadlock); the
// fix is to stage bounded memory and stream the overflow into a Go-owned temp file.
//
// The temp file lives in the SAME directory as the final target so finalize is an
// atomic rename, not a cross-device copy. The writable dir is chosen up front (with
// the same unwritable→system-Downloads fallback as SaveDownload) because once GBs
// are streamed we can't cheaply switch volumes.

type streamingDownload struct {
	file *os.File
	dir  string // the writable dir chosen at Begin; the final file is renamed into it
	tmp  string // the .part temp path under dir
}

var (
	streamMu        sync.Mutex
	streamDownloads = map[string]*streamingDownload{}
)

// BeginStreamingDownload opens a .part temp file under a writable download dir for
// the session. dir == "" → system Downloads; an unwritable dir falls back to system
// Downloads (mirrors SaveDownload). A stale entry for the same session id is closed
// and removed first, so a retried transfer never leaks a handle or temp file.
func BeginStreamingDownload(dir, sessionId string) error {
	if dir == "" {
		dir = DefaultDownloadDir()
	}
	// Drop any stale handle for this session FIRST (a retried transfer). Its temp
	// path equals the new one (both derive from the session id), so removing it
	// after opening the fresh file would delete the file we just created. Clean
	// up first; openStreamTemp then re-creates the temp via O_TRUNC.
	streamMu.Lock()
	if prev, ok := streamDownloads[sessionId]; ok {
		_ = prev.file.Close()
		_ = os.Remove(prev.tmp)
		delete(streamDownloads, sessionId)
	}
	streamMu.Unlock()

	f, dirUsed, tmp, err := openStreamTemp(dir, sessionId)
	if err != nil {
		return err
	}
	streamMu.Lock()
	streamDownloads[sessionId] = &streamingDownload{file: f, dir: dirUsed, tmp: tmp}
	streamMu.Unlock()
	return nil
}

// openStreamTemp creates a writable .part temp file under dir, retrying into the
// system Downloads dir if dir is unwritable (mirrors SaveDownload's fallback).
func openStreamTemp(dir, sessionId string) (*os.File, string, string, error) {
	f, tmp, err := tryStreamTemp(dir, sessionId)
	if err == nil {
		return f, dir, tmp, nil
	}
	if def := DefaultDownloadDir(); def != dir {
		if f2, tmp2, ferr := tryStreamTemp(def, sessionId); ferr == nil {
			return f2, def, tmp2, nil
		}
	}
	return nil, "", "", err
}

func tryStreamTemp(dir, sessionId string) (*os.File, string, error) {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return nil, "", err
	}
	tmp := filepath.Join(dir, ".cdrop-"+sanitizeSessionID(sessionId)+".part")
	f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
	if err != nil {
		return nil, "", err
	}
	return f, tmp, nil
}

// sanitizeSessionID reduces the peer-supplied session id to filename-safe chars so
// it can name the .part temp file. It only has to be unique and path-safe, not
// reversible — the real filename is applied at finalize.
func sanitizeSessionID(id string) string {
	var b strings.Builder
	for _, r := range id {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
			b.WriteRune(r)
		}
	}
	s := b.String()
	if s == "" {
		s = "session"
	}
	if len(s) > 64 {
		s = s[:64]
	}
	return s
}

// AppendStreamingDownload writes a batch of bytes to the session's temp file.
func AppendStreamingDownload(sessionId string, data []byte) error {
	streamMu.Lock()
	d := streamDownloads[sessionId]
	streamMu.Unlock()
	if d == nil {
		return fmt.Errorf("no streaming download for session %q", sessionId)
	}
	_, err := d.file.Write(data)
	return err
}

// FinalizeStreamingDownload closes the temp file and renames it to a non-colliding
// final name under the same dir (name sanitized like SaveDownload), then reveals
// it. Returns the absolute path written.
func FinalizeStreamingDownload(sessionId, name string) (string, error) {
	streamMu.Lock()
	d := streamDownloads[sessionId]
	delete(streamDownloads, sessionId)
	streamMu.Unlock()
	if d == nil {
		return "", fmt.Errorf("no streaming download for session %q", sessionId)
	}
	if err := d.file.Close(); err != nil {
		_ = os.Remove(d.tmp)
		return "", err
	}
	safe := sanitizeFileName(name)
	if safe == "" {
		safe = "cdrop-download"
	}
	target := uniquePath(d.dir, safe)
	if err := os.Rename(d.tmp, target); err != nil {
		_ = os.Remove(d.tmp)
		return "", err
	}
	revealInFileManager(target)
	return target, nil
}

// AbortStreamingDownload closes and deletes the session's temp file (cancel /
// failure path). No-op when the session isn't streaming.
func AbortStreamingDownload(sessionId string) {
	streamMu.Lock()
	d := streamDownloads[sessionId]
	delete(streamDownloads, sessionId)
	streamMu.Unlock()
	if d == nil {
		return
	}
	_ = d.file.Close()
	_ = os.Remove(d.tmp)
}
