package main

import (
	"crypto/sha1"
	"encoding/hex"
	"errors"
	"io"
	"os"
	"path/filepath"
	"strings"
)

// sanitizeFilename trims a user-supplied filename and rejects anything
// dangerous: path separators, leading dots, NUL bytes, the `.`/`..`
// pseudo-names, anything absurdly long. PhotoPrism's indexer is happy
// with most filename shapes; we lock down the ones a malicious or
// careless caller might use to escape the folder.
func sanitizeFilename(name string) (string, bool) {
	s := strings.TrimSpace(name)
	if s == "" || len(s) > 240 {
		return "", false
	}
	if strings.HasPrefix(s, ".") {
		return "", false
	}
	if s == "." || s == ".." {
		return "", false
	}
	if strings.ContainsAny(s, "/\\\x00") {
		return "", false
	}
	return s, true
}

// resolveUnderRoot takes a user-supplied relative path and returns its
// absolute form, but only when the resolved location lives under the
// configured originals root. Symlink escapes are caught by resolving the
// parent through filepath.EvalSymlinks first.
//
// `mustExist=false` is for the *target* of a rename/create where the
// terminal path isn't on disk yet; the parent still has to exist and
// still has to be inside the root.
func resolveUnderRoot(root, rel string, mustExist bool) (string, error) {
	if rel == "" {
		return "", errors.New("empty path")
	}
	clean := strings.TrimLeft(rel, "/")
	if clean == "" || clean == "." {
		return "", errors.New("empty path")
	}
	for _, seg := range strings.Split(clean, "/") {
		if seg == "" || seg == ".." {
			return "", errors.New("path traversal")
		}
	}
	abs := filepath.Join(root, clean)
	parent := filepath.Dir(abs)

	parentReal, err := filepath.EvalSymlinks(parent)
	if err != nil {
		return "", err
	}
	if !sameOrUnder(parentReal, root) {
		return "", errors.New("parent escapes originals root")
	}
	if mustExist {
		if _, err := os.Stat(abs); err != nil {
			return "", err
		}
	}
	return abs, nil
}

// ensureWithinOriginals checks that an absolute path's parent resolves to
// somewhere inside the root after symlink evaluation. Used for the
// already-resolved-on-disk paths returned by PhotoPrism's Files[].
func ensureWithinOriginals(root, absPath string) bool {
	real, err := filepath.EvalSymlinks(filepath.Dir(absPath))
	if err != nil {
		return false
	}
	return sameOrUnder(real, root)
}

func sameOrUnder(p, root string) bool {
	if p == root {
		return true
	}
	return strings.HasPrefix(p, root+string(os.PathSeparator))
}

// uniqueName resolves "destDir/basename" against collisions by appending
// `-1`, `-2`, … to the stem. Caps at 1000 attempts so a runaway loop
// can't pin the goroutine forever.
func uniqueName(destDir, basename string) (abs, name string, ok bool) {
	ext := filepath.Ext(basename)
	stem := strings.TrimSuffix(basename, ext)
	for i := 0; i < 1000; i++ {
		candidate := basename
		if i > 0 {
			candidate = stem + "-" + itoa(i) + ext
		}
		p := filepath.Join(destDir, candidate)
		if _, err := os.Stat(p); errors.Is(err, os.ErrNotExist) {
			return p, candidate, true
		}
	}
	return "", "", false
}

// itoa is the tiny stdlib-free formatter we use inside hot loops.
func itoa(n int) string {
	if n == 0 {
		return "0"
	}
	neg := n < 0
	if neg {
		n = -n
	}
	var buf [20]byte
	i := len(buf)
	for n > 0 {
		i--
		buf[i] = byte('0' + n%10)
		n /= 10
	}
	if neg {
		i--
		buf[i] = '-'
	}
	return string(buf[i:])
}

// fileEntry is the per-file row walkFiles emits. relPath stays root-
// relative so it can land in API responses unchanged.
type fileEntry struct {
	RelPath string
	AbsPath string
	Size    int64
}

// supportedExts mirrors the Node prototype's whitelist. PhotoPrism
// itself walks the same set; we keep the list in lock-step so callers
// don't see "duplicate" warnings about files PhotoPrism would ignore.
var supportedExts = map[string]struct{}{
	".jpg": {}, ".jpeg": {}, ".png": {}, ".heic": {}, ".heif": {},
	".tiff": {}, ".tif": {}, ".gif": {}, ".bmp": {}, ".webp": {}, ".avif": {},
	".mov": {}, ".mp4": {}, ".m4v": {}, ".avi": {}, ".mkv": {}, ".webm": {},
	".dng": {}, ".cr2": {}, ".cr3": {}, ".nef": {}, ".arw": {},
	".orf": {}, ".rw2": {}, ".raw": {},
}

// walkFiles enumerates every supported media file under root, skipping
// dotfiles/dotdirs (matches PhotoPrism's indexer and our own quarantine
// folder). Errors on individual entries are swallowed so a single
// permission-denied dir doesn't abort the whole scan.
func walkFiles(root string) ([]fileEntry, error) {
	var out []fileEntry
	err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
		if err != nil {
			// Permission errors etc. — skip the offending subtree but
			// keep walking. The dup-scan endpoint is best-effort.
			if d != nil && d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		name := d.Name()
		if p != root && strings.HasPrefix(name, ".") {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			return nil
		}
		ext := strings.ToLower(filepath.Ext(name))
		if _, ok := supportedExts[ext]; !ok {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			return nil
		}
		rel, err := filepath.Rel(root, p)
		if err != nil {
			return nil
		}
		out = append(out, fileEntry{
			RelPath: rel,
			AbsPath: p,
			Size:    info.Size(),
		})
		return nil
	})
	return out, err
}

// sha1File streams the file through a SHA1 hasher so a 4GB ProRes clip
// doesn't blow the process's RAM. Returns the hex digest.
func sha1File(absPath string) (string, error) {
	f, err := os.Open(absPath)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha1.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}
