package main

// Everything that touches the disk lives here: turning a name supplied by a
// phone into a name this computer is willing to write, choosing a filename
// that cannot collide with one already there, and streaming the bytes in via a
// .part file so a half-finished transfer is never mistaken for a real one.
//
// Two rules hold everywhere in this file:
//   - nothing is ever deleted or overwritten, and
//   - a filename that arrives over the network is treated as hostile input.

import (
	"errors"
	"fmt"
	"io"
	"mime"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode"
)

// maxNameLength caps the sanitised base name. Long enough for a real
// screenshot name, short enough that the timestamp prefix and a collision
// suffix still fit inside every filesystem's own limit.
const maxNameLength = 96

// windowsDeviceNames cannot be used as a filename on Windows even with an
// extension: opening "con.txt" opens the console. SnapBeam is cross-platform
// and the save folder may well be on a share a Windows machine reads, so the
// names are refused everywhere rather than only on Windows.
var windowsDeviceNames = map[string]bool{
	"con": true, "prn": true, "aux": true, "nul": true,
	"com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
	"com6": true, "com7": true, "com8": true, "com9": true,
	"lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
	"lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
}

// safeFileName reduces an arbitrary name from the network to something safe to
// join onto the save folder. It never returns an empty string and never
// returns a name containing a path separator, so the result cannot escape the
// directory it is joined to.
//
// The approach is a strict allow-list rather than a block-list of nasty
// sequences: only ASCII letters, digits, space, dot, dash and underscore
// survive. That disposes of the whole family of Unicode look-alike attacks
// (fullwidth solidus, fraction slash, division slash, right-to-left override)
// in one step, because a look-alike separator is not on the list either.
func safeFileName(name string) string {
	// A Windows client may send a backslash path; treat both separators the
	// same before taking the last element.
	name = strings.ReplaceAll(name, "\\", "/")
	if i := strings.LastIndexByte(name, '/'); i >= 0 {
		name = name[i+1:]
	}
	// A drive-relative name such as "C:file" still has a colon in it.
	if i := strings.LastIndexByte(name, ':'); i >= 0 {
		name = name[i+1:]
	}

	var b strings.Builder
	for _, r := range name {
		switch {
		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
			b.WriteRune(r)
		case r == '.' || r == '-' || r == '_':
			b.WriteRune(r)
		case r == ' ' || unicode.IsSpace(r):
			// Newlines, tabs and every exotic Unicode space collapse to one
			// ordinary space, so a name can never span two log lines.
			b.WriteRune(' ')
		default:
			b.WriteRune('_')
		}
	}
	out := b.String()

	// Collapse runs of spaces, then trim the characters Windows silently
	// strips from the end of a filename.
	for strings.Contains(out, "  ") {
		out = strings.ReplaceAll(out, "  ", " ")
	}
	out = strings.Trim(out, " ")
	out = strings.TrimRight(out, ". ")

	// "." and ".." are directory entries, not names.
	if out == "" || out == "." || out == ".." || strings.Trim(out, ".") == "" {
		return "untitled"
	}

	// A leading dot hides the file on Unix; a name from a phone should not.
	out = strings.TrimLeft(out, ".")
	if out == "" {
		return "untitled"
	}

	// Reserved Windows device names, with or without an extension.
	stem := out
	if i := strings.IndexByte(out, '.'); i >= 0 {
		stem = out[:i]
	}
	if windowsDeviceNames[strings.ToLower(stem)] {
		out = "_" + out
	}

	if len(out) > maxNameLength {
		ext := filepath.Ext(out)
		if len(ext) > 16 {
			ext = ""
		}
		keep := maxNameLength - len(ext)
		if keep < 1 {
			keep = 1
		}
		out = out[:keep] + ext
	}
	return out
}

// splitExt splits a filename into its stem and extension so a collision
// suffix can be inserted before the dot.
func splitExt(name string) (string, string) {
	ext := filepath.Ext(name)
	if ext == "" || ext == name {
		return name, ""
	}
	return name[:len(name)-len(ext)], ext
}

// uniquePath returns a path inside dir that does not exist yet, starting from
// the preferred name and inserting "-2", "-3" and so on before the extension.
// It also refuses a name whose .part sibling exists, so two transfers landing
// at the same instant cannot fight over one temporary file.
func uniquePath(dir, name string) (string, error) {
	stem, ext := splitExt(name)
	for i := 1; i < 10000; i++ {
		candidate := name
		if i > 1 {
			candidate = fmt.Sprintf("%s-%d%s", stem, i, ext)
		}
		full := filepath.Join(dir, candidate)
		_, err := os.Lstat(full)
		if err == nil {
			continue // taken
		}
		if !errors.Is(err, os.ErrNotExist) {
			return "", fmt.Errorf("cannot inspect %s: %w", full, err)
		}
		if _, err := os.Lstat(full + ".part"); err == nil {
			continue // a transfer in flight is already claiming this name
		}
		return full, nil
	}
	return "", fmt.Errorf("cannot find a free name for %q in %s", name, dir)
}

// stampedName prefixes a name with the local date and time, which is what
// makes a folder full of screenshots sortable and a collision unlikely in the
// first place.
func stampedName(t time.Time, name string) string {
	return t.Format("2006-01-02_150405") + "_" + name
}

// errTooLarge is returned when an upload exceeds the configured cap.
var errTooLarge = errors.New("upload exceeds the size limit")

// savedItem is one thing that landed on this computer.
type savedItem struct {
	Path  string
	Name  string
	Bytes int64
	Kind  string // "image/png", "text/plain", ...
	When  time.Time
	Via   string // "LocalSend" or "browser"
	From  string // sender alias or address, for the log line only
}

// saveMu serialises the pick-a-name-then-create-it step. Two phones sending at
// the same moment would otherwise be able to choose the same path between the
// Lstat and the create.
var saveMu sync.Mutex

// saveStream writes r into dir under a timestamped, collision-free name
// derived from suggested. It writes to "<final>.part" and renames on success,
// so a reader watching the folder never sees a partial file under its final
// name. At most maxBytes are accepted; one byte more and the partial file is
// removed and errTooLarge returned.
//
// The .part file is the only file SnapBeam ever deletes, and only one it
// created itself moments earlier.
func saveStream(dir, suggested string, r io.Reader, maxBytes int64, now time.Time) (savedItem, error) {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return savedItem{}, fmt.Errorf("cannot create %s: %w", dir, err)
	}
	name := stampedName(now, safeFileName(suggested))

	saveMu.Lock()
	full, err := uniquePath(dir, name)
	if err != nil {
		saveMu.Unlock()
		return savedItem{}, err
	}
	part := full + ".part"
	f, err := os.OpenFile(part, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	saveMu.Unlock()
	if err != nil {
		return savedItem{}, fmt.Errorf("cannot create %s: %w", part, err)
	}

	// Read one byte past the cap: if it arrives, the sender lied about the
	// size or never declared one, and the transfer is refused.
	written, copyErr := io.Copy(f, io.LimitReader(r, maxBytes+1))
	closeErr := f.Close()
	switch {
	case copyErr != nil:
		os.Remove(part)
		return savedItem{}, fmt.Errorf("cannot write %s: %w", part, copyErr)
	case closeErr != nil:
		os.Remove(part)
		return savedItem{}, fmt.Errorf("cannot write %s: %w", part, closeErr)
	case written > maxBytes:
		os.Remove(part)
		return savedItem{}, fmt.Errorf("%w of %s", errTooLarge, humanBytes(maxBytes))
	}

	if err := os.Rename(part, full); err != nil {
		os.Remove(part)
		return savedItem{}, fmt.Errorf("cannot finish %s: %w", full, err)
	}
	return savedItem{
		Path:  full,
		Name:  filepath.Base(full),
		Bytes: written,
		Kind:  kindOf(full),
		When:  now,
	}, nil
}

// kindOf guesses a content type from the extension, which is all that is
// needed for a listing. Unknown extensions read as application/octet-stream.
func kindOf(path string) string {
	ct := mime.TypeByExtension(strings.ToLower(filepath.Ext(path)))
	if ct == "" {
		return "application/octet-stream"
	}
	if i := strings.IndexByte(ct, ';'); i >= 0 {
		ct = ct[:i]
	}
	return ct
}

// extensionFor turns a declared content type into a file extension, so text
// pasted on a phone lands as .txt and a screenshot as .png even when the
// client sends no filename at all.
func extensionFor(contentType string) string {
	ct := strings.ToLower(strings.TrimSpace(contentType))
	if i := strings.IndexByte(ct, ';'); i >= 0 {
		ct = strings.TrimSpace(ct[:i])
	}
	switch ct {
	case "text/plain", "":
		return ".txt"
	case "image/png":
		return ".png"
	case "image/jpeg", "image/jpg":
		return ".jpg"
	case "image/gif":
		return ".gif"
	case "image/webp":
		return ".webp"
	case "image/heic":
		return ".heic"
	case "application/pdf":
		return ".pdf"
	}
	if exts, err := mime.ExtensionsByType(ct); err == nil && len(exts) > 0 {
		sort.Strings(exts)
		return exts[0]
	}
	return ".bin"
}

// ---------------------------------------------------------------------------
// Listing
// ---------------------------------------------------------------------------

// listSaved returns everything in dir, newest first. Half-finished .part files
// are shown too, clearly marked, because silently hiding them would make a
// stalled transfer look like nothing happened.
func listSaved(dir string) ([]savedItem, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, err
	}
	var out []savedItem
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		kind := kindOf(e.Name())
		if strings.HasSuffix(e.Name(), ".part") {
			kind = "(incomplete transfer)"
		}
		out = append(out, savedItem{
			Path:  filepath.Join(dir, e.Name()),
			Name:  e.Name(),
			Bytes: info.Size(),
			Kind:  kind,
			When:  info.ModTime(),
		})
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].When.After(out[j].When) })
	return out, nil
}

// ---------------------------------------------------------------------------
// Clipboard
// ---------------------------------------------------------------------------

// clipboardTool is an external program that reads text on stdin and puts it on
// the system clipboard. There is no portable way to do this from Go without
// linking against the platform's window system, so SnapBeam shells out to
// whatever the operating system already ships and does without when there is
// nothing to shell out to.
type clipboardTool struct {
	Name string
	Args []string
}

// clipboardCandidates lists the tools worth trying, best first. wl-copy comes
// before xclip because a Wayland session usually has both and only wl-copy
// talks to the compositor that is actually running.
func clipboardCandidates() []clipboardTool {
	return []clipboardTool{
		{"clip.exe", nil}, // Windows, and WSL reaching the Windows host
		{"pbcopy", nil},   // macOS
		{"wl-copy", nil},  // Linux, Wayland
		{"xclip", []string{"-selection", "clipboard"}}, // Linux, X11
		{"xsel", []string{"--clipboard", "--input"}},   // Linux, X11 alternative
	}
}

// findClipboardTool returns the first candidate present on this machine.
func findClipboardTool() (clipboardTool, bool) {
	for _, c := range clipboardCandidates() {
		if _, err := exec.LookPath(c.Name); err == nil {
			return c, true
		}
	}
	return clipboardTool{}, false
}

// copyToClipboard puts text on the system clipboard, returning the name of the
// tool used. A failure here is never allowed to fail a transfer: the file is
// already safely on disk by the time this is called, and "saved to <path>" is
// a perfectly good outcome on a machine with no clipboard at all.
func copyToClipboard(text string) (string, error) {
	tool, ok := findClipboardTool()
	if !ok {
		return "", errors.New("no clipboard tool found (clip.exe, pbcopy, wl-copy, xclip or xsel)")
	}
	cmd := exec.Command(tool.Name, tool.Args...)
	cmd.Stdin = strings.NewReader(text)
	if err := cmd.Run(); err != nil {
		return tool.Name, fmt.Errorf("%s failed: %w", tool.Name, err)
	}
	return tool.Name, nil
}

// clipboardTextLimit is the largest amount of text worth putting on the
// clipboard automatically. A multi-megabyte log file is a file, not a snippet.
const clipboardTextLimit = 256 << 10

// isTextKind reports whether a content type holds text a person would want on
// the clipboard.
func isTextKind(kind string) bool {
	k := strings.ToLower(kind)
	return strings.HasPrefix(k, "text/") || k == "application/json" || k == "application/xml"
}

// defaultSaveDir is the folder incoming items land in unless --dir says
// otherwise: a SnapBeam folder in the user's home directory.
func defaultSaveDir() string {
	home, err := os.UserHomeDir()
	if err != nil || home == "" {
		wd, err := os.Getwd()
		if err != nil {
			return "SnapBeam"
		}
		return filepath.Join(wd, "SnapBeam")
	}
	return filepath.Join(home, "SnapBeam")
}
