// Command fileops is a bulk rename engine with a pattern-template language,
// all-or-nothing collision safety, and a reversible undo journal.
package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strconv"
	"strings"
	"time"
)

const toolVersion = "1.0.0"

func reorderFlags(args []string, valueFlags map[string]bool) []string {
	var flags, positional []string
	for i := 0; i < len(args); i++ {
		a := args[i]
		name := strings.TrimLeft(a, "-")
		if strings.HasPrefix(a, "-") && valueFlags[name] {
			flags = append(flags, a)
			if i+1 < len(args) {
				i++
				flags = append(flags, args[i])
			}
			continue
		}
		if strings.HasPrefix(a, "-") {
			flags = append(flags, a)
			continue
		}
		positional = append(positional, a)
	}
	return append(flags, positional...)
}

func humanBytes(n int64) string {
	const unit = 1024
	if n < unit {
		return fmt.Sprintf("%d B", n)
	}
	div, exp := int64(unit), 0
	for x := n / unit; x >= unit; x /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}

// ---------------------------------------------------------------- usage

func usage() {
	fmt.Fprint(os.Stderr, usageText)
}

const usageText = `fileops ` + toolVersion + ` - bulk rename engine with collision safety and undo

USAGE
  fileops rename  <dir> --match <regex> --to <template> [options]
  fileops preview <dir> --match <regex> --to <template> [options]
  fileops undo    --journal <file> [--apply] [--json]
  fileops help

COMMANDS
  rename    Plan renames; DRY RUN unless --apply is given.
  preview   Always read-only. Never touches the filesystem.
  undo      Reverse every rename recorded in a journal, newest first.
            DRY RUN unless --apply is given.

OPTIONS
  --match <regex>   Go regular expression matched against the FILE NAME.
  --to <template>   Destination name template (see TEMPLATE TOKENS).
  --recursive       Descend into subdirectories. Files are renamed in place;
                    directories themselves are never renamed.
  --start <N>       First value of the {n} counter (default 1).
  --pad <N>         Zero-pad {n} to N digits (default 0, no padding).
  --apply           Actually perform the renames. Without it, nothing changes.
  --journal <file>  Write (rename) or read (undo) the JSON undo journal.
  --json            Emit machine-readable JSON on stdout.
  -h, --help        Show this help and exit 0.

TEMPLATE TOKENS
  {1} {2} ...       Regex capture groups ({0} is the whole match).
  {name}            Original file name without its extension.
  {ext}             Extension including the leading dot ("" if none).
  {n}               Sequence counter, honours --start and --pad.
  {date}            File mtime formatted YYYY-MM-DD.
  {size}            File size in bytes.
  {upper:X}         Uppercase whatever X renders to.
  {lower:X}         Lowercase whatever X renders to.
  {title:X}         Title-case whatever X renders to.
                    Transforms nest, e.g. {upper:{name}}-{n}{ext}

SAFETY
  Files are visited in a deterministic sorted order, so {n} is reproducible.
  Every destination is computed up front. If two sources would land on the
  same name, or a destination already exists on disk and is not itself being
  renamed away, the ENTIRE run aborts having moved nothing. Swaps and cycles
  (a->b, b->a) are performed correctly via temporary names. Nothing is ever
  deleted or overwritten.

EXAMPLES
  fileops preview ./photos --match '^IMG_(\d+)\.jpg$' --to 'holiday-{1}.jpg'
  fileops rename ./photos --match '.*' --to 'photo-{n}{ext}' --pad 3 \
      --apply --journal ./photos.journal.json
  fileops undo --journal ./photos.journal.json --apply
`

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "fileops: "+format+"\n", args...)
	os.Exit(1)
}

// ---------------------------------------------------------------- template

type nodeKind int

const (
	kindLit nodeKind = iota
	kindGroup
	kindName
	kindExt
	kindCounter
	kindDate
	kindSize
	kindUpper
	kindLower
	kindTitle
)

type tnode struct {
	kind  nodeKind
	lit   string
	group int
	sub   []tnode
}

func parseTemplate(s string) ([]tnode, error) {
	var out []tnode
	var lit strings.Builder
	flush := func() {
		if lit.Len() > 0 {
			out = append(out, tnode{kind: kindLit, lit: lit.String()})
			lit.Reset()
		}
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if c == '}' {
			return nil, fmt.Errorf("unbalanced '}' at offset %d in template", i)
		}
		if c != '{' {
			lit.WriteByte(c)
			continue
		}
		depth, j := 1, i+1
		for ; j < len(s); j++ {
			if s[j] == '{' {
				depth++
			} else if s[j] == '}' {
				depth--
				if depth == 0 {
					break
				}
			}
		}
		if depth != 0 {
			return nil, fmt.Errorf("unbalanced '{' at offset %d in template", i)
		}
		flush()
		nd, err := parseToken(s[i+1 : j])
		if err != nil {
			return nil, err
		}
		out = append(out, nd)
		i = j
	}
	flush()
	return out, nil
}

func parseToken(inner string) (tnode, error) {
	if inner == "" {
		return tnode{}, fmt.Errorf("empty template token {}")
	}
	if idx := strings.Index(inner, ":"); idx > 0 {
		fn := inner[:idx]
		var kind nodeKind
		switch fn {
		case "upper":
			kind = kindUpper
		case "lower":
			kind = kindLower
		case "title":
			kind = kindTitle
		default:
			kind = kindLit
		}
		if kind != kindLit {
			sub, err := parseTemplate(inner[idx+1:])
			if err != nil {
				return tnode{}, err
			}
			return tnode{kind: kind, sub: sub}, nil
		}
	}
	switch inner {
	case "name":
		return tnode{kind: kindName}, nil
	case "ext":
		return tnode{kind: kindExt}, nil
	case "n":
		return tnode{kind: kindCounter}, nil
	case "date":
		return tnode{kind: kindDate}, nil
	case "size":
		return tnode{kind: kindSize}, nil
	}
	if isAllDigits(inner) {
		g, err := strconv.Atoi(inner)
		if err != nil {
			return tnode{}, fmt.Errorf("invalid capture group token {%s}", inner)
		}
		return tnode{kind: kindGroup, group: g}, nil
	}
	return tnode{}, fmt.Errorf("unknown template token {%s} (valid: {N} {name} {ext} {n} {date} {size} {upper:X} {lower:X} {title:X})", inner)
}

func isAllDigits(s string) bool {
	for _, r := range s {
		if r < '0' || r > '9' {
			return false
		}
	}
	return len(s) > 0
}

// maxGroup returns the highest capture-group index referenced by the template.
func maxGroup(nodes []tnode) int {
	m := 0
	for _, nd := range nodes {
		if nd.kind == kindGroup && nd.group > m {
			m = nd.group
		}
		if s := maxGroup(nd.sub); s > m {
			m = s
		}
	}
	return m
}

type renderCtx struct {
	groups  []string
	name    string
	ext     string
	counter string
	date    string
	size    string
}

func render(nodes []tnode, ctx renderCtx) string {
	var b strings.Builder
	for _, nd := range nodes {
		switch nd.kind {
		case kindLit:
			b.WriteString(nd.lit)
		case kindGroup:
			if nd.group < len(ctx.groups) {
				b.WriteString(ctx.groups[nd.group])
			}
		case kindName:
			b.WriteString(ctx.name)
		case kindExt:
			b.WriteString(ctx.ext)
		case kindCounter:
			b.WriteString(ctx.counter)
		case kindDate:
			b.WriteString(ctx.date)
		case kindSize:
			b.WriteString(ctx.size)
		case kindUpper:
			b.WriteString(strings.ToUpper(render(nd.sub, ctx)))
		case kindLower:
			b.WriteString(strings.ToLower(render(nd.sub, ctx)))
		case kindTitle:
			b.WriteString(titleCase(render(nd.sub, ctx)))
		}
	}
	return b.String()
}

// titleCase upper-cases the first letter of every word and lower-cases the
// rest. Word boundaries are any non-letter, non-digit rune.
func titleCase(s string) string {
	var b strings.Builder
	startOfWord := true
	for _, r := range s {
		switch {
		case isWordRune(r) && startOfWord:
			b.WriteString(strings.ToUpper(string(r)))
			startOfWord = false
		case isWordRune(r):
			b.WriteString(strings.ToLower(string(r)))
		default:
			b.WriteRune(r)
			startOfWord = true
		}
	}
	return b.String()
}

func isWordRune(r rune) bool {
	return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r > 127
}

// ---------------------------------------------------------------- plan

type planEntry struct {
	src     string // absolute source path
	dst     string // absolute destination path
	relSrc  string
	relDst  string
	size    int64
	noop    bool
	tmpPath string
}

type collision struct {
	Destination string   `json:"destination"`
	Sources     []string `json:"sources"`
	Reason      string   `json:"reason"`
}

// checkCollisions validates a plan. It returns every problem found; a non-empty
// result means the caller must abort without touching a single file.
func checkCollisions(entries []planEntry) []collision {
	srcSet := make(map[string]bool, len(entries))
	for _, e := range entries {
		srcSet[e.src] = true
	}
	byDst := make(map[string][]string)
	var order []string
	for _, e := range entries {
		if _, seen := byDst[e.dst]; !seen {
			order = append(order, e.dst)
		}
		byDst[e.dst] = append(byDst[e.dst], e.relSrc)
	}
	var out []collision
	for _, dst := range order {
		srcs := byDst[dst]
		if len(srcs) > 1 {
			sorted := append([]string(nil), srcs...)
			sort.Strings(sorted)
			out = append(out, collision{
				Destination: dst,
				Sources:     sorted,
				Reason:      fmt.Sprintf("%d source files would be renamed to the same destination", len(sorted)),
			})
			continue
		}
		if srcSet[dst] {
			continue // destination is itself being renamed away (or is a no-op)
		}
		if _, err := os.Lstat(dst); err == nil {
			out = append(out, collision{
				Destination: dst,
				Sources:     srcs,
				Reason:      "destination already exists on disk and is not part of this batch",
			})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Destination < out[j].Destination })
	return out
}

// applyPlan performs the renames in two phases (src -> unique temp -> dst) so
// that swaps and cycles such as a->b, b->a work correctly. Any failure is
// rolled back, leaving the tree as it was found.
func applyPlan(entries []planEntry) error {
	moves := make([]planEntry, 0, len(entries))
	for _, e := range entries {
		if !e.noop {
			moves = append(moves, e)
		}
	}
	// Phase 1: park every source under a unique temporary name.
	staged := 0
	for i := range moves {
		tmp, err := uniqueTemp(filepath.Dir(moves[i].src), i)
		if err != nil {
			rollbackPhase1(moves[:staged])
			return err
		}
		if err := os.Rename(moves[i].src, tmp); err != nil {
			rollbackPhase1(moves[:staged])
			return fmt.Errorf("staging %s: %w", moves[i].relSrc, err)
		}
		moves[i].tmpPath = tmp
		staged++
	}
	// Phase 2: move every temporary into its final destination.
	for i := range moves {
		if err := os.Rename(moves[i].tmpPath, moves[i].dst); err != nil {
			for j := 0; j < i; j++ {
				_ = os.Rename(moves[j].dst, moves[j].tmpPath)
			}
			rollbackPhase1(moves)
			return fmt.Errorf("renaming %s -> %s: %w", moves[i].relSrc, moves[i].relDst, err)
		}
	}
	return nil
}

func rollbackPhase1(moves []planEntry) {
	for i := len(moves) - 1; i >= 0; i-- {
		if moves[i].tmpPath != "" {
			_ = os.Rename(moves[i].tmpPath, moves[i].src)
		}
	}
}

func uniqueTemp(dir string, i int) (string, error) {
	for attempt := 0; attempt < 1000; attempt++ {
		cand := filepath.Join(dir, fmt.Sprintf(".fileops-tmp-%d-%d-%d", os.Getpid(), i, attempt))
		if _, err := os.Lstat(cand); os.IsNotExist(err) {
			return cand, nil
		}
	}
	return "", fmt.Errorf("could not allocate a temporary name in %s", dir)
}

// ---------------------------------------------------------------- journal

type journalEntry struct {
	From string `json:"from"`
	To   string `json:"to"`
}

type journalFile struct {
	Tool      string         `json:"tool"`
	Version   string         `json:"version"`
	Created   string         `json:"created"`
	Directory string         `json:"directory"`
	Entries   []journalEntry `json:"entries"`
}

func writeJournal(path, dir string, entries []planEntry) error {
	jf := journalFile{
		Tool:      "fileops",
		Version:   toolVersion,
		Created:   time.Now().UTC().Format(time.RFC3339),
		Directory: dir,
	}
	for _, e := range entries {
		if e.noop {
			continue
		}
		jf.Entries = append(jf.Entries, journalEntry{From: e.src, To: e.dst})
	}
	data, err := json.MarshalIndent(jf, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(path, data, 0o644)
}

func readJournal(path string) (journalFile, error) {
	var jf journalFile
	data, err := os.ReadFile(path)
	if err != nil {
		return jf, err
	}
	if err := json.Unmarshal(data, &jf); err != nil {
		return jf, fmt.Errorf("journal %s is not valid fileops JSON: %w", path, err)
	}
	if jf.Tool != "fileops" {
		return jf, fmt.Errorf("journal %s was not written by fileops", path)
	}
	return jf, nil
}

// ---------------------------------------------------------------- output

type jsonEntryOut struct {
	From      string `json:"from"`
	To        string `json:"to"`
	Unchanged bool   `json:"unchanged"`
}

type jsonReport struct {
	Tool       string         `json:"tool"`
	Version    string         `json:"version"`
	Command    string         `json:"command"`
	Directory  string         `json:"directory,omitempty"`
	Journal    string         `json:"journal,omitempty"`
	Applied    bool           `json:"applied"`
	DryRun     bool           `json:"dry_run"`
	Matched    int            `json:"matched"`
	Planned    int            `json:"planned"`
	Unchanged  int            `json:"unchanged"`
	TotalBytes int64          `json:"total_bytes"`
	TotalHuman string         `json:"total_human"`
	Aborted    bool           `json:"aborted"`
	Collisions []collision    `json:"collisions"`
	Missing    []string       `json:"missing"`
	Entries    []jsonEntryOut `json:"entries"`
}

func emitJSON(r jsonReport) {
	if r.Collisions == nil {
		r.Collisions = []collision{}
	}
	if r.Missing == nil {
		r.Missing = []string{}
	}
	if r.Entries == nil {
		r.Entries = []jsonEntryOut{}
	}
	data, err := json.MarshalIndent(r, "", "  ")
	if err != nil {
		fail("encoding JSON: %v", err)
	}
	fmt.Println(string(data))
}

func reportCollisions(cols []collision, base string) {
	fmt.Fprintf(os.Stderr, "fileops: ABORTED - %d collision(s) detected. Nothing was renamed.\n", len(cols))
	for _, c := range cols {
		fmt.Fprintf(os.Stderr, "  collision on %q: %s\n", relTo(base, c.Destination), c.Reason)
		for _, s := range c.Sources {
			fmt.Fprintf(os.Stderr, "      source: %s\n", s)
		}
	}
	fmt.Fprintln(os.Stderr, "fileops: this run is all-or-nothing; resolve the collisions and try again.")
}

func relTo(base, p string) string {
	if base == "" {
		return p
	}
	if r, err := filepath.Rel(base, p); err == nil && !strings.HasPrefix(r, "..") {
		return r
	}
	return p
}

// ---------------------------------------------------------------- main

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// questions the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch os.Args[1] {
	case "-h", "--help", "help":
		fmt.Print(usageText)
		os.Exit(0)
	case "rename":
		os.Exit(cmdRename("rename", os.Args[2:]))
	case "preview":
		os.Exit(cmdRename("preview", os.Args[2:]))
	case "undo":
		cmdUndo(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "fileops: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	return fs
}

func wantsHelp(args []string) bool {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			return true
		}
	}
	return false
}

// cmdRename reports the exit code it wants rather than calling os.Exit itself.
// "nothing matched" and "these names would collide" are RESULTS, not crashes,
// and the guided session run by a double-click has to be able to print such a
// result and then keep the window open. main turns the returned code back into
// the same exit status the command has always produced.
func cmdRename(cmd string, rawArgs []string) int {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		return 0
	}
	valueFlags := map[string]bool{
		"match": true, "to": true, "start": true, "pad": true, "journal": true,
	}
	args := reorderFlags(rawArgs, valueFlags)

	fs := newFlagSet(cmd)
	var (
		match     = fs.String("match", "", "regular expression matched against the file name")
		to        = fs.String("to", "", "destination name template")
		recursive = fs.Bool("recursive", false, "descend into subdirectories")
		start     = fs.Int("start", 1, "first value of the {n} counter")
		pad       = fs.Int("pad", 0, "zero-pad {n} to this width")
		apply     = fs.Bool("apply", false, "actually perform the renames")
		journal   = fs.String("journal", "", "path of the undo journal to write")
		asJSON    = fs.Bool("json", false, "emit JSON")
	)
	if err := fs.Parse(args); err != nil {
		return 1
	}
	if cmd == "preview" {
		*apply = false
	}
	rest := fs.Args()
	if len(rest) != 1 {
		fmt.Fprintf(os.Stderr, "fileops: %s needs exactly one directory argument (got %d)\n\n", cmd, len(rest))
		usage()
		return 1
	}
	if *match == "" || *to == "" {
		fmt.Fprintf(os.Stderr, "fileops: %s requires both --match and --to\n\n", cmd)
		usage()
		return 1
	}
	if *pad < 0 {
		fail("--pad must not be negative")
	}

	dir, err := filepath.Abs(rest[0])
	if err != nil {
		fail("resolving %s: %v", rest[0], err)
	}
	info, err := os.Stat(dir)
	if err != nil {
		fail("cannot read directory %s: %v", rest[0], err)
	}
	if !info.IsDir() {
		fail("%s is not a directory", rest[0])
	}

	re, err := regexp.Compile(*match)
	if err != nil {
		fail("invalid --match regular expression: %v", err)
	}
	nodes, err := parseTemplate(*to)
	if err != nil {
		fail("invalid --to template: %v", err)
	}
	if g := maxGroup(nodes); g > re.NumSubexp() {
		fail("invalid --to template: {%d} refers to capture group %d but --match defines only %d", g, g, re.NumSubexp())
	}

	var journalAbs string
	if *journal != "" {
		journalAbs, err = filepath.Abs(*journal)
		if err != nil {
			fail("resolving --journal path: %v", err)
		}
	}

	files, err := scanFiles(dir, *recursive, journalAbs)
	if err != nil {
		fail("scanning %s: %v", rest[0], err)
	}

	// Build the plan in deterministic sorted order so {n} is reproducible.
	var entries []planEntry
	var totalBytes int64
	counter := *start
	for _, f := range files {
		base := filepath.Base(f.path)
		m := re.FindStringSubmatch(base)
		if m == nil {
			continue
		}
		ext := filepath.Ext(base)
		ctx := renderCtx{
			groups:  m,
			name:    strings.TrimSuffix(base, ext),
			ext:     ext,
			counter: padNum(counter, *pad),
			date:    f.mtime.Format("2006-01-02"),
			size:    strconv.FormatInt(f.size, 10),
		}
		counter++
		newName := render(nodes, ctx)
		if err := validateName(newName, filepath.Dir(f.path), dir, base); err != nil {
			fail("%v", err)
		}
		dst := filepath.Join(filepath.Dir(f.path), newName)
		entries = append(entries, planEntry{
			src:    f.path,
			dst:    dst,
			relSrc: relTo(dir, f.path),
			relDst: relTo(dir, dst),
			size:   f.size,
			noop:   f.path == dst,
		})
		totalBytes += f.size
	}

	planned, unchanged := 0, 0
	for _, e := range entries {
		if e.noop {
			unchanged++
		} else {
			planned++
		}
	}

	if len(entries) == 0 {
		if *asJSON {
			emitJSON(jsonReport{
				Tool: "fileops", Version: toolVersion, Command: cmd, Directory: dir,
				Applied: false, DryRun: !*apply, TotalHuman: humanBytes(0),
			})
		} else if len(files) == 0 {
			fmt.Printf("%s: no files found under %s - nothing to do.\n", cmd, dir)
		} else {
			fmt.Printf("%s: 0 of %d file(s) matched --match %q - nothing to do.\n", cmd, len(files), *match)
		}
		return 0
	}

	cols := checkCollisions(entries)
	if len(cols) > 0 {
		if *asJSON {
			emitJSON(jsonReport{
				Tool: "fileops", Version: toolVersion, Command: cmd, Directory: dir,
				Applied: false, DryRun: !*apply, Matched: len(entries), Planned: planned,
				Unchanged: unchanged, TotalBytes: totalBytes, TotalHuman: humanBytes(totalBytes),
				Aborted: true, Collisions: cols,
			})
		}
		reportCollisions(cols, dir)
		return 1
	}

	applied := false
	if *apply && planned > 0 {
		if err := applyPlan(entries); err != nil {
			fail("rename failed (tree rolled back, nothing changed): %v", err)
		}
		applied = true
		if journalAbs != "" {
			if err := writeJournal(journalAbs, dir, entries); err != nil {
				fail("renames succeeded but the journal could not be written to %s: %v", journalAbs, err)
			}
		}
	}

	if *asJSON {
		rep := jsonReport{
			Tool: "fileops", Version: toolVersion, Command: cmd, Directory: dir,
			Journal: journalAbs, Applied: applied, DryRun: !*apply,
			Matched: len(entries), Planned: planned, Unchanged: unchanged,
			TotalBytes: totalBytes, TotalHuman: humanBytes(totalBytes),
		}
		for _, e := range entries {
			rep.Entries = append(rep.Entries, jsonEntryOut{From: e.relSrc, To: e.relDst, Unchanged: e.noop})
		}
		emitJSON(rep)
		return 0
	}

	if applied {
		fmt.Println("APPLIED - files renamed:")
	} else {
		fmt.Println("DRY RUN - nothing was changed. Re-run with --apply to commit.")
	}
	for _, e := range entries {
		if e.noop {
			fmt.Printf("  = %s (already matches the template)\n", e.relSrc)
			continue
		}
		fmt.Printf("  %s -> %s\n", e.relSrc, e.relDst)
	}
	fmt.Printf("%d matched, %d rename(s) %s, %d unchanged, %s total\n",
		len(entries), planned, verb(applied), unchanged, humanBytes(totalBytes))
	if applied {
		if journalAbs != "" {
			fmt.Printf("undo journal: %s\n", journalAbs)
			fmt.Printf("to reverse:   fileops undo --journal %s --apply\n", journalAbs)
		} else {
			fmt.Fprintln(os.Stderr, "fileops: warning - no --journal given, this rename cannot be undone by fileops.")
		}
	}
	return 0
}

func verb(applied bool) string {
	if applied {
		return "performed"
	}
	return "planned"
}

func padNum(n, width int) string {
	s := strconv.Itoa(n)
	neg := ""
	if strings.HasPrefix(s, "-") {
		neg, s = "-", s[1:]
	}
	for len(s) < width {
		s = "0" + s
	}
	return neg + s
}

func validateName(name, parent, root, src string) error {
	if name == "" {
		return fmt.Errorf("template produced an empty name for %s", relTo(root, filepath.Join(parent, src)))
	}
	if name == "." || name == ".." {
		return fmt.Errorf("template produced the reserved name %q for %s", name, relTo(root, filepath.Join(parent, src)))
	}
	if strings.ContainsRune(name, os.PathSeparator) || strings.ContainsRune(name, '/') {
		return fmt.Errorf("template produced %q for %s: destination names must not contain a path separator (fileops renames in place, it does not move files between directories)", name, relTo(root, filepath.Join(parent, src)))
	}
	if strings.ContainsRune(name, 0) {
		return fmt.Errorf("template produced a name containing a NUL byte for %s", relTo(root, filepath.Join(parent, src)))
	}
	return nil
}

// ---------------------------------------------------------------- scanning

type scanned struct {
	path  string
	size  int64
	mtime time.Time
}

func scanFiles(root string, recursive bool, skip string) ([]scanned, error) {
	var out []scanned
	add := func(p string, fi fs.FileInfo) {
		if skip != "" && p == skip {
			return
		}
		if strings.HasPrefix(filepath.Base(p), ".fileops-tmp-") {
			return
		}
		out = append(out, scanned{path: p, size: fi.Size(), mtime: fi.ModTime()})
	}
	if recursive {
		err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				return nil
			}
			fi, err := d.Info()
			if err != nil {
				return err
			}
			add(p, fi)
			return nil
		})
		if err != nil {
			return nil, err
		}
	} else {
		ents, err := os.ReadDir(root)
		if err != nil {
			return nil, err
		}
		for _, d := range ents {
			if d.IsDir() {
				continue
			}
			fi, err := d.Info()
			if err != nil {
				return nil, err
			}
			add(filepath.Join(root, d.Name()), fi)
		}
	}
	sort.Slice(out, func(i, j int) bool {
		a, b := filepath.ToSlash(out[i].path), filepath.ToSlash(out[j].path)
		da, db := filepath.ToSlash(filepath.Dir(out[i].path)), filepath.ToSlash(filepath.Dir(out[j].path))
		if da != db {
			return da < db
		}
		return a < b
	})
	return out, nil
}

// ---------------------------------------------------------------- undo

func cmdUndo(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{"journal": true})
	fs := newFlagSet("undo")
	var (
		journal = fs.String("journal", "", "path of the undo journal to reverse")
		apply   = fs.Bool("apply", false, "actually perform the reversal")
		asJSON  = fs.Bool("json", false, "emit JSON")
	)
	if err := fs.Parse(args); err != nil {
		os.Exit(1)
	}
	if len(fs.Args()) != 0 {
		fmt.Fprintf(os.Stderr, "fileops: undo takes no positional arguments (got %v)\n\n", fs.Args())
		usage()
		os.Exit(1)
	}
	if *journal == "" {
		fmt.Fprintln(os.Stderr, "fileops: undo requires --journal <file>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}
	journalAbs, err := filepath.Abs(*journal)
	if err != nil {
		fail("resolving --journal path: %v", err)
	}
	jf, err := readJournal(journalAbs)
	if err != nil {
		fail("%v", err)
	}
	if len(jf.Entries) == 0 {
		if *asJSON {
			emitJSON(jsonReport{Tool: "fileops", Version: toolVersion, Command: "undo",
				Journal: journalAbs, DryRun: !*apply, TotalHuman: humanBytes(0)})
		} else {
			fmt.Printf("undo: journal %s records no renames - nothing to do.\n", journalAbs)
		}
		os.Exit(0)
	}

	// Reverse order: the last rename performed is the first one undone.
	var entries []planEntry
	var missing []string
	var totalBytes int64
	for i := len(jf.Entries) - 1; i >= 0; i-- {
		je := jf.Entries[i]
		fi, err := os.Lstat(je.To)
		if err != nil {
			missing = append(missing, je.To)
			continue
		}
		entries = append(entries, planEntry{
			src:    je.To,
			dst:    je.From,
			relSrc: relTo(jf.Directory, je.To),
			relDst: relTo(jf.Directory, je.From),
			size:   fi.Size(),
			noop:   je.To == je.From,
		})
		totalBytes += fi.Size()
	}
	if len(missing) > 0 {
		if *asJSON {
			emitJSON(jsonReport{Tool: "fileops", Version: toolVersion, Command: "undo",
				Journal: journalAbs, DryRun: !*apply, Matched: len(jf.Entries),
				Aborted: true, Missing: missing, TotalHuman: humanBytes(0)})
		}
		fmt.Fprintf(os.Stderr, "fileops: ABORTED - %d file(s) recorded in the journal are missing; nothing was moved.\n", len(missing))
		for _, m := range missing {
			fmt.Fprintf(os.Stderr, "      missing: %s\n", m)
		}
		os.Exit(1)
	}

	cols := checkCollisions(entries)
	if len(cols) > 0 {
		if *asJSON {
			emitJSON(jsonReport{Tool: "fileops", Version: toolVersion, Command: "undo",
				Journal: journalAbs, DryRun: !*apply, Matched: len(entries),
				Aborted: true, Collisions: cols, TotalHuman: humanBytes(totalBytes)})
		}
		reportCollisions(cols, jf.Directory)
		os.Exit(1)
	}

	planned := 0
	for _, e := range entries {
		if !e.noop {
			planned++
		}
	}
	applied := false
	if *apply && planned > 0 {
		if err := applyPlan(entries); err != nil {
			fail("undo failed (tree rolled back, nothing changed): %v", err)
		}
		applied = true
	}

	if *asJSON {
		rep := jsonReport{Tool: "fileops", Version: toolVersion, Command: "undo",
			Journal: journalAbs, Applied: applied, DryRun: !*apply,
			Matched: len(entries), Planned: planned, TotalBytes: totalBytes,
			TotalHuman: humanBytes(totalBytes)}
		for _, e := range entries {
			rep.Entries = append(rep.Entries, jsonEntryOut{From: e.relSrc, To: e.relDst, Unchanged: e.noop})
		}
		emitJSON(rep)
		os.Exit(0)
	}

	if applied {
		fmt.Println("APPLIED - renames reversed:")
	} else {
		fmt.Println("DRY RUN - nothing was changed. Re-run with --apply to commit.")
	}
	for _, e := range entries {
		fmt.Printf("  %s -> %s\n", e.relSrc, e.relDst)
	}
	fmt.Printf("%d journal entr(ies), %d reversal(s) %s, %s total\n",
		len(entries), planned, verb(applied), humanBytes(totalBytes))
	if applied {
		fmt.Printf("journal %s has now been replayed backwards; keep it only if you plan to redo the rename manually.\n", journalAbs)
	}
}
