// Command filedeck runs spreadsheet-driven bulk file operations: a CSV plan of
// explicit move/copy/rename/retire rows is validated in full, executed
// all-or-nothing, and reversed from a journal.
package main

import (
	"encoding/csv"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"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 = `filedeck ` + toolVersion + ` - spreadsheet-driven bulk file operations

USAGE
  filedeck validate --plan <plan.csv> [--root <dir>] [--json]
  filedeck apply    --plan <plan.csv> [--root <dir>] --journal <file>
                    [--trash <dir>] [--apply] [--json]
  filedeck undo     --journal <file> [--apply] [--json]
  filedeck template
  filedeck help

COMMANDS
  validate  Check an entire plan without touching anything. Every problem is
            reported at once, with the CSV row number of each offending row.
  apply     Validate the plan and REFUSE THE WHOLE RUN if anything is wrong,
            then execute it. DRY RUN unless --apply is given.
  undo      Reverse every operation recorded in a journal, newest first.
            DRY RUN unless --apply is given.
  template  Print an example plan CSV (header row plus one row per action).

OPTIONS
  --plan <file>     CSV plan to read (see PLAN FORMAT).
  --root <dir>      Directory that relative paths resolve against and that
                    every source and destination must stay inside.
                    Default: the current working directory.
  --journal <file>  Write (apply) or read (undo) the JSON undo journal.
  --trash <dir>     Where retired files are moved to. Nothing is ever deleted.
                    Default: <root>/.filedeck-trash
  --apply           Actually perform the operations. Without it, nothing
                    on disk changes and no journal is written.
  --json            Emit a machine-readable report on stdout.
  -h, --help        Show this help and exit 0.

PLAN FORMAT
  Real RFC 4180 CSV, so quoted fields containing commas and double quotes work.
  The first non-comment line is the header and must contain the columns
  action, source and destination (in any order, case-insensitive). Extra
  columns - notes, owner, ticket - are ignored, so a spreadsheet export can be
  used as-is. Blank lines and lines starting with # are skipped.

  ACTION   DESTINATION MEANS
  move     New path for the file. Parent directories are created.
  copy     New path for a duplicate. The source is left in place.
  rename   New NAME inside the source's own directory (no path separators).
  retire   Optional path inside --trash. Empty means the file keeps its
           layout relative to --root. Retire NEVER deletes anything.

SAFETY
  The whole plan is checked before a single byte moves: sources must exist,
  sources must be unique, destinations must not collide with each other or
  with files already on disk, and nothing may escape --root. If any row is
  bad the entire run is refused and nothing is executed. A successful run
  writes a journal that reverses it exactly.

EXAMPLES
  filedeck template > plan.csv
  filedeck validate --plan plan.csv --root ./records
  filedeck apply --plan plan.csv --root ./records --journal ./run.json \
      --trash ./retired --apply
  filedeck undo --journal ./run.json --apply
`

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

// ---------------------------------------------------------------- plan model

type planRow struct {
	line    int // line number in the CSV file
	dataRow int // ordinal among data rows (header excluded)
	action  string
	source  string
	dest    string
}

type operation struct {
	Row     int    `json:"row"`
	DataRow int    `json:"data_row"`
	Action  string `json:"action"`
	From    string `json:"from"`
	To      string `json:"to"`
	FromRel string `json:"from_rel"`
	ToRel   string `json:"to_rel"`
	Bytes   int64  `json:"bytes"`
	Human   string `json:"human"`
}

type problem struct {
	Row         int    `json:"row"`
	DataRow     int    `json:"data_row"`
	Code        string `json:"code"`
	Action      string `json:"action"`
	Source      string `json:"source"`
	Destination string `json:"destination"`
	Message     string `json:"message"`
}

type report struct {
	Tool       string      `json:"tool"`
	Version    string      `json:"version"`
	Command    string      `json:"command"`
	Root       string      `json:"root,omitempty"`
	Plan       string      `json:"plan,omitempty"`
	Journal    string      `json:"journal,omitempty"`
	Trash      string      `json:"trash,omitempty"`
	OK         bool        `json:"ok"`
	DryRun     bool        `json:"dry_run"`
	Applied    bool        `json:"applied"`
	Rows       int         `json:"rows"`
	Operations int         `json:"operations"`
	TotalBytes int64       `json:"total_bytes"`
	TotalHuman string      `json:"total_human"`
	Problems   []problem   `json:"problems"`
	Ops        []operation `json:"ops"`
}

func emitJSON(r report) {
	if r.Problems == nil {
		r.Problems = []problem{}
	}
	if r.Ops == nil {
		r.Ops = []operation{}
	}
	data, err := json.MarshalIndent(r, "", "  ")
	if err != nil {
		fail("encoding JSON: %v", err)
	}
	fmt.Println(string(data))
}

var validActions = map[string]bool{"move": true, "copy": true, "rename": true, "retire": true}

// ---------------------------------------------------------------- plan reading

func readPlan(path string) ([]planRow, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read plan %s: %w", path, err)
	}
	if len(strings.TrimSpace(string(data))) == 0 {
		return nil, fmt.Errorf("plan %s is empty", path)
	}
	r := csv.NewReader(strings.NewReader(string(data)))
	r.Comment = '#'
	r.FieldsPerRecord = -1
	r.TrimLeadingSpace = true

	var (
		rows                 []planRow
		iAct, iSrc, iDst     = -1, -1, -1
		haveHeader           bool
		dataRow, headerWidth int
	)
	for {
		rec, err := r.Read()
		if errors.Is(err, io.EOF) {
			break
		}
		if err != nil {
			var pe *csv.ParseError
			if errors.As(err, &pe) {
				return nil, fmt.Errorf("plan %s line %d: %v", path, pe.Line, pe.Err)
			}
			return nil, fmt.Errorf("plan %s: %v", path, err)
		}
		line, _ := r.FieldPos(0)
		if !haveHeader {
			for i, h := range rec {
				switch strings.ToLower(strings.TrimSpace(h)) {
				case "action":
					iAct = i
				case "source":
					iSrc = i
				case "destination":
					iDst = i
				}
			}
			var missing []string
			if iAct < 0 {
				missing = append(missing, "action")
			}
			if iSrc < 0 {
				missing = append(missing, "source")
			}
			if iDst < 0 {
				missing = append(missing, "destination")
			}
			if len(missing) > 0 {
				return nil, fmt.Errorf("plan %s line %d: header row is missing required column(s): %s (found: %s)",
					path, line, strings.Join(missing, ", "), strings.Join(rec, ", "))
			}
			haveHeader = true
			headerWidth = len(rec)
			continue
		}
		need := iAct
		for _, i := range []int{iSrc, iDst} {
			if i > need {
				need = i
			}
		}
		if len(rec) <= need {
			return nil, fmt.Errorf("plan %s line %d: row has %d field(s) but the header declares %d; "+
				"action/source/destination need at least %d", path, line, len(rec), headerWidth, need+1)
		}
		dataRow++
		rows = append(rows, planRow{
			line:    line,
			dataRow: dataRow,
			action:  strings.ToLower(strings.TrimSpace(rec[iAct])),
			source:  strings.TrimSpace(rec[iSrc]),
			dest:    strings.TrimSpace(rec[iDst]),
		})
	}
	if !haveHeader {
		return nil, fmt.Errorf("plan %s has no header row (expected columns action, source, destination)", path)
	}
	if len(rows) == 0 {
		return nil, fmt.Errorf("plan %s has a header row but no operation rows", path)
	}
	return rows, nil
}

// ---------------------------------------------------------------- validation

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
}

// within reports whether p lies strictly inside base.
func within(base, p string) bool {
	rel, err := filepath.Rel(base, p)
	if err != nil {
		return false
	}
	if rel == "." || rel == ".." {
		return false
	}
	return !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}

func resolve(root, p string) string {
	if filepath.IsAbs(p) {
		return filepath.Clean(p)
	}
	return filepath.Join(root, p)
}

// nearestExisting walks up from p until it finds a path that exists.
func nearestExisting(p string) (string, os.FileInfo, bool) {
	for cur := filepath.Dir(p); ; cur = filepath.Dir(cur) {
		if fi, err := os.Lstat(cur); err == nil {
			return cur, fi, true
		}
		if parent := filepath.Dir(cur); parent == cur {
			return cur, nil, false
		}
	}
}

// trashTarget picks a free path inside trash for rel, adding -2, -3 ... before
// the extension when something is already parked there.
func trashTarget(trash, rel string, taken map[string]bool) string {
	cand := filepath.Join(trash, rel)
	if !taken[cand] {
		if _, err := os.Lstat(cand); err != nil {
			return cand
		}
	}
	ext := filepath.Ext(cand)
	stem := strings.TrimSuffix(cand, ext)
	for n := 2; n < 100000; n++ {
		c := fmt.Sprintf("%s-%d%s", stem, n, ext)
		if taken[c] {
			continue
		}
		if _, err := os.Lstat(c); err != nil {
			return c
		}
	}
	return cand
}

// buildPlan turns rows into operations and reports EVERY problem it finds.
func buildPlan(rows []planRow, root, trash string) ([]operation, []problem) {
	var ops []operation
	var probs []problem
	add := func(r planRow, code, msg string) {
		probs = append(probs, problem{
			Row: r.line, DataRow: r.dataRow, Code: code, Action: r.action,
			Source: r.source, Destination: r.dest, Message: msg,
		})
	}

	// Pass 1: actions and sources.
	type resolved struct {
		row    planRow
		srcAbs string
		size   int64
		valid  bool
	}
	srcSeen := make(map[string]int)
	freed := make(map[string]bool)
	items := make([]resolved, 0, len(rows))
	for _, r := range rows {
		it := resolved{row: r}
		if r.action == "" {
			add(r, "missing_action", "action column is empty")
		} else if !validActions[r.action] {
			add(r, "unknown_action", fmt.Sprintf("unknown action %q (valid: move, copy, rename, retire)", r.action))
		}
		if r.source == "" {
			add(r, "missing_source", "source column is empty")
			items = append(items, it)
			continue
		}
		abs := resolve(root, r.source)
		it.srcAbs = abs
		if !within(root, abs) {
			add(r, "source_escapes_root", fmt.Sprintf("source %q resolves to %s which is outside --root %s", r.source, abs, root))
			items = append(items, it)
			continue
		}
		fi, err := os.Lstat(abs)
		switch {
		case err != nil:
			add(r, "source_missing", fmt.Sprintf("source %q does not exist (looked for %s)", r.source, abs))
		case fi.IsDir():
			add(r, "source_is_directory", fmt.Sprintf("source %q is a directory; filedeck operates on files", r.source))
		case !fi.Mode().IsRegular():
			add(r, "source_not_regular", fmt.Sprintf("source %q is not a regular file", r.source))
		default:
			it.size = fi.Size()
			it.valid = true
		}
		if first, dup := srcSeen[abs]; dup {
			add(r, "duplicate_source", fmt.Sprintf("source %q is already used by row %d; every source may appear only once", r.source, first))
		} else {
			srcSeen[abs] = r.line
		}
		if validActions[r.action] && r.action != "copy" {
			freed[abs] = true
		}
		items = append(items, it)
	}

	// Pass 2: destinations.
	destSeen := make(map[string]int)
	trashTaken := make(map[string]bool)
	for _, it := range items {
		r := it.row
		if it.srcAbs == "" || !validActions[r.action] {
			continue
		}
		var dstAbs string
		switch r.action {
		case "rename":
			if r.dest == "" {
				add(r, "missing_destination", "rename needs a destination (the new file name)")
				continue
			}
			if strings.ContainsRune(r.dest, '/') || strings.ContainsRune(r.dest, filepath.Separator) {
				add(r, "bad_rename_destination", fmt.Sprintf("rename destination %q contains a path separator; rename stays in the source's own directory (use move to relocate)", r.dest))
				continue
			}
			if r.dest == "." || r.dest == ".." {
				add(r, "bad_rename_destination", fmt.Sprintf("rename destination %q is a reserved name", r.dest))
				continue
			}
			dstAbs = filepath.Join(filepath.Dir(it.srcAbs), r.dest)
		case "retire":
			rel := r.dest
			if rel == "" {
				rel = relTo(root, it.srcAbs)
			}
			if filepath.IsAbs(rel) {
				add(r, "bad_retire_destination", fmt.Sprintf("retire destination %q must be relative to --trash", r.dest))
				continue
			}
			cand := filepath.Join(trash, rel)
			if !within(trash, cand) {
				add(r, "destination_escapes_trash", fmt.Sprintf("retire destination %q escapes the trash directory %s", r.dest, trash))
				continue
			}
			dstAbs = trashTarget(trash, rel, trashTaken)
			trashTaken[dstAbs] = true
		default: // move, copy
			if r.dest == "" {
				add(r, "missing_destination", fmt.Sprintf("%s needs a destination path", r.action))
				continue
			}
			dstAbs = resolve(root, r.dest)
			if !within(root, dstAbs) {
				add(r, "destination_escapes_root", fmt.Sprintf("destination %q resolves to %s which is outside --root %s", r.dest, dstAbs, root))
				continue
			}
		}
		if dstAbs == it.srcAbs {
			add(r, "destination_is_source", fmt.Sprintf("destination %q is the source itself; nothing to do", r.dest))
			continue
		}
		if first, dup := destSeen[dstAbs]; dup {
			add(r, "destination_collision", fmt.Sprintf("destination %s is already claimed by row %d; two rows cannot land on the same path", shortPath(root, trash, dstAbs), first))
			continue
		}
		destSeen[dstAbs] = r.line
		if fi, err := os.Lstat(dstAbs); err == nil {
			if fi.IsDir() {
				add(r, "destination_is_directory", fmt.Sprintf("destination %s already exists and is a directory", shortPath(root, trash, dstAbs)))
				continue
			}
			if !freed[dstAbs] {
				add(r, "destination_exists", fmt.Sprintf("destination %s already exists on disk and no row in this plan moves it away", shortPath(root, trash, dstAbs)))
				continue
			}
		} else if anc, afi, ok := nearestExisting(dstAbs); ok && !afi.IsDir() {
			add(r, "destination_parent_not_directory", fmt.Sprintf("destination %s cannot be created: %s is a file, not a directory", shortPath(root, trash, dstAbs), shortPath(root, trash, anc)))
			continue
		}
		if !it.valid {
			continue // source problem already reported; no executable operation
		}
		ops = append(ops, operation{
			Row: r.line, DataRow: r.dataRow, Action: r.action,
			From: it.srcAbs, To: dstAbs,
			FromRel: relTo(root, it.srcAbs), ToRel: shortPath(root, trash, dstAbs),
			Bytes: it.size, Human: humanBytes(it.size),
		})
	}

	sort.SliceStable(probs, func(i, j int) bool {
		if probs[i].Row != probs[j].Row {
			return probs[i].Row < probs[j].Row
		}
		return probs[i].Code < probs[j].Code
	})
	return ops, probs
}

func shortPath(root, trash, p string) string {
	if trash != "" && within(trash, p) {
		return "<trash>/" + filepath.ToSlash(relTo(trash, p))
	}
	if within(root, p) {
		return filepath.ToSlash(relTo(root, p))
	}
	return p
}

func reportProblems(probs []problem, what string) {
	fmt.Fprintf(os.Stderr, "filedeck: PLAN REJECTED - %d problem(s) found in %s. Nothing was changed.\n", len(probs), what)
	for _, p := range probs {
		if p.DataRow > 0 {
			fmt.Fprintf(os.Stderr, "  row %d (data row %d)  %s: %s\n", p.Row, p.DataRow, p.Code, p.Message)
			continue
		}
		fmt.Fprintf(os.Stderr, "  row %d  %s: %s\n", p.Row, p.Code, p.Message)
	}
	fmt.Fprintln(os.Stderr, "filedeck: this run is all-or-nothing; fix every row above and try again.")
}

// ---------------------------------------------------------------- file work

func uniqueTemp(dir, tag string, i int) (string, error) {
	for attempt := 0; attempt < 1000; attempt++ {
		cand := filepath.Join(dir, fmt.Sprintf(".filedeck-%s-%d-%d-%d", tag, 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)
}

func copyFile(src, dst string) (int64, error) {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return 0, err
	}
	in, err := os.Open(src)
	if err != nil {
		return 0, err
	}
	defer in.Close()
	fi, err := in.Stat()
	if err != nil {
		return 0, err
	}
	tmp, err := os.CreateTemp(filepath.Dir(dst), ".filedeck-copy-*")
	if err != nil {
		return 0, err
	}
	tmpName := tmp.Name()
	n, err := io.Copy(tmp, in)
	if err == nil {
		err = tmp.Sync()
	}
	if cerr := tmp.Close(); err == nil {
		err = cerr
	}
	if err == nil {
		err = os.Chmod(tmpName, fi.Mode().Perm())
	}
	if err == nil {
		err = os.Rename(tmpName, dst)
	}
	if err != nil {
		_ = os.Remove(tmpName)
		return 0, err
	}
	_ = os.Chtimes(dst, time.Now(), fi.ModTime())
	return n, nil
}

func moveFile(src, dst string) error {
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		return err
	}
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	if _, err := copyFile(src, dst); err != nil {
		return err
	}
	return os.Remove(src)
}

// execute performs the whole plan or rolls back to the starting state.
func execute(ops []operation) error {
	var rollback []func()
	unwind := func() {
		for i := len(rollback) - 1; i >= 0; i-- {
			rollback[i]()
		}
	}
	// Phase 1: park every source that leaves its place under a temp name, so
	// that a destination freed by one row is available to another.
	staged := make(map[int]string)
	for i, op := range ops {
		if op.Action == "copy" {
			continue
		}
		tmp, err := uniqueTemp(filepath.Dir(op.From), "stage", i)
		if err != nil {
			unwind()
			return err
		}
		if err := os.Rename(op.From, tmp); err != nil {
			unwind()
			return fmt.Errorf("row %d: staging %s: %w", op.Row, op.FromRel, err)
		}
		staged[i] = tmp
		from, to := op.From, tmp
		rollback = append(rollback, func() { _ = os.Rename(to, from) })
	}
	// Phase 2: duplicates, reading from the staged copy when the source moved.
	for i, op := range ops {
		if op.Action != "copy" {
			continue
		}
		src := op.From
		if t, ok := staged[i]; ok {
			src = t
		}
		if _, err := copyFile(src, op.To); err != nil {
			unwind()
			return fmt.Errorf("row %d: copying %s -> %s: %w", op.Row, op.FromRel, op.ToRel, err)
		}
		made := op.To
		rollback = append(rollback, func() { _ = os.Remove(made) })
	}
	// Phase 3: land every staged file on its destination.
	for i, op := range ops {
		tmp, ok := staged[i]
		if !ok {
			continue
		}
		if err := moveFile(tmp, op.To); err != nil {
			unwind()
			return fmt.Errorf("row %d: moving %s -> %s: %w", op.Row, op.FromRel, op.ToRel, err)
		}
		dst, back := op.To, tmp
		rollback = append(rollback, func() { _ = moveFile(dst, back) })
	}
	return nil
}

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

type journalOp struct {
	Row    int    `json:"row"`
	Action string `json:"action"`
	From   string `json:"from"`
	To     string `json:"to"`
}

type journalFile struct {
	Tool    string      `json:"tool"`
	Version string      `json:"version"`
	Created string      `json:"created"`
	Root    string      `json:"root"`
	Plan    string      `json:"plan"`
	Trash   string      `json:"trash"`
	Ops     []journalOp `json:"ops"`
}

func writeJournal(path, root, plan, trash string, ops []operation) error {
	jf := journalFile{
		Tool: "filedeck", Version: toolVersion,
		Created: time.Now().UTC().Format(time.RFC3339),
		Root:    root, Plan: plan, Trash: trash,
	}
	for _, op := range ops {
		jf.Ops = append(jf.Ops, journalOp{Row: op.Row, Action: op.Action, From: op.From, To: op.To})
	}
	data, err := json.MarshalIndent(jf, "", "  ")
	if err != nil {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, append(data, '\n'), 0o644)
}

func readJournal(path string) (journalFile, error) {
	var jf journalFile
	data, err := os.ReadFile(path)
	if err != nil {
		return jf, fmt.Errorf("cannot read journal %s: %w", path, err)
	}
	if err := json.Unmarshal(data, &jf); err != nil {
		return jf, fmt.Errorf("journal %s is not valid filedeck JSON: %v", path, err)
	}
	if jf.Tool != "filedeck" {
		return jf, fmt.Errorf("journal %s was not written by filedeck", path)
	}
	if len(jf.Ops) == 0 {
		return jf, fmt.Errorf("journal %s records no operations", path)
	}
	return jf, nil
}

// ---------------------------------------------------------------- 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 "validate":
		os.Exit(cmdValidate(os.Args[2:]))
	case "apply":
		cmdApply(os.Args[2:])
	case "undo":
		cmdUndo(os.Args[2:])
	case "template":
		cmdTemplate(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "filedeck: 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
}

func mustAbs(what, p string) string {
	abs, err := filepath.Abs(p)
	if err != nil {
		fail("resolving %s path %q: %v", what, p, err)
	}
	return abs
}

func rootDir(p string) string {
	root := mustAbs("--root", p)
	fi, err := os.Stat(root)
	if err != nil {
		fail("cannot read --root %s: %v", root, err)
	}
	if !fi.IsDir() {
		fail("--root %s is not a directory", root)
	}
	return root
}

const templateText = `# filedeck plan - one file operation per row.
# Lines starting with # and blank lines are ignored.
# Extra columns (notes below) are ignored, so a spreadsheet export works as-is.
# Paths are relative to --root. Quote any field containing a comma or a quote.
action,source,destination,notes
move,inbox/2024-report.txt,archive/2024/report.txt,move relocates the file
copy,inbox/contract.txt,shared/contract.txt,copy leaves the source in place
rename,inbox/draft final.txt,draft-final.txt,rename stays in the same folder
retire,inbox/old-memo.txt,,retire moves the file into --trash and never deletes

# The next row shows real CSV quoting: the file name contains a comma.
# move,"inbox/Smith, John.txt",archive/2024/smith-john.txt,quoted path
`

func cmdTemplate(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	if len(rawArgs) != 0 {
		fmt.Fprintf(os.Stderr, "filedeck: template takes no arguments (got %v)\n\n", rawArgs)
		usage()
		os.Exit(1)
	}
	fmt.Print(templateText)
}

// cmdValidate reports the exit code it wants rather than calling os.Exit
// itself. Finding problems in a plan is a RESULT, not a crash, and the guided
// session run by a double-click has to be able to print that result and then
// keep the window open. main turns the returned code back into the same exit
// status the command has always produced.
func cmdValidate(rawArgs []string) int {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		return 0
	}
	args := reorderFlags(rawArgs, map[string]bool{"plan": true, "root": true, "trash": true})
	fs := newFlagSet("validate")
	var (
		plan   = fs.String("plan", "", "CSV plan to validate")
		root   = fs.String("root", ".", "directory relative paths resolve against")
		trash  = fs.String("trash", "", "trash directory used by retire rows")
		asJSON = fs.Bool("json", false, "emit JSON")
	)
	if err := fs.Parse(args); err != nil {
		return 1
	}
	if len(fs.Args()) != 0 {
		fmt.Fprintf(os.Stderr, "filedeck: validate takes no positional arguments (got %v)\n\n", fs.Args())
		usage()
		return 1
	}
	if *plan == "" {
		fmt.Fprintln(os.Stderr, "filedeck: validate requires --plan <plan.csv>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		return 1
	}
	planAbs := mustAbs("--plan", *plan)
	rootAbs := rootDir(*root)
	trashAbs := filepath.Join(rootAbs, ".filedeck-trash")
	if *trash != "" {
		trashAbs = mustAbs("--trash", *trash)
	}

	rows, err := readPlan(planAbs)
	if err != nil {
		fail("%v", err)
	}
	ops, probs := buildPlan(rows, rootAbs, trashAbs)

	var total int64
	for _, op := range ops {
		total += op.Bytes
	}
	if *asJSON {
		emitJSON(report{
			Tool: "filedeck", Version: toolVersion, Command: "validate",
			Root: rootAbs, Plan: planAbs, Trash: trashAbs,
			OK: len(probs) == 0, DryRun: true, Rows: len(rows),
			Operations: len(ops), TotalBytes: total, TotalHuman: humanBytes(total),
			Problems: probs, Ops: ops,
		})
		if len(probs) > 0 {
			return 1
		}
		return 0
	}
	if len(probs) > 0 {
		reportProblems(probs, filepath.Base(planAbs))
		return 1
	}
	fmt.Printf("PLAN OK - %d row(s), %d operation(s), 0 problem(s), %s total.\n", len(rows), len(ops), humanBytes(total))
	for _, op := range ops {
		fmt.Printf("  row %-3d %-6s %s -> %s\n", op.Row, op.Action, op.FromRel, op.ToRel)
	}
	fmt.Printf("root: %s\n", rootAbs)
	return 0
}

func cmdApply(rawArgs []string) {
	if wantsHelp(rawArgs) {
		fmt.Print(usageText)
		os.Exit(0)
	}
	args := reorderFlags(rawArgs, map[string]bool{"plan": true, "root": true, "journal": true, "trash": true})
	fs := newFlagSet("apply")
	var (
		plan    = fs.String("plan", "", "CSV plan to execute")
		root    = fs.String("root", ".", "directory relative paths resolve against")
		journal = fs.String("journal", "", "path of the undo journal to write")
		trash   = fs.String("trash", "", "directory retired files are moved into")
		apply   = fs.Bool("apply", false, "actually perform the operations")
		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, "filedeck: apply takes no positional arguments (got %v)\n\n", fs.Args())
		usage()
		os.Exit(1)
	}
	if *plan == "" || *journal == "" {
		fmt.Fprintln(os.Stderr, "filedeck: apply requires --plan <plan.csv> and --journal <file>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}
	planAbs := mustAbs("--plan", *plan)
	rootAbs := rootDir(*root)
	journalAbs := mustAbs("--journal", *journal)
	trashAbs := filepath.Join(rootAbs, ".filedeck-trash")
	if *trash != "" {
		trashAbs = mustAbs("--trash", *trash)
	}

	rows, err := readPlan(planAbs)
	if err != nil {
		fail("%v", err)
	}
	ops, probs := buildPlan(rows, rootAbs, trashAbs)

	var total int64
	for _, op := range ops {
		total += op.Bytes
	}
	if len(probs) > 0 {
		if *asJSON {
			emitJSON(report{
				Tool: "filedeck", Version: toolVersion, Command: "apply",
				Root: rootAbs, Plan: planAbs, Journal: journalAbs, Trash: trashAbs,
				OK: false, DryRun: !*apply, Applied: false, Rows: len(rows),
				Operations: len(ops), TotalBytes: total, TotalHuman: humanBytes(total),
				Problems: probs,
			})
		}
		reportProblems(probs, filepath.Base(planAbs))
		fmt.Fprintf(os.Stderr, "filedeck: 0 of %d row(s) were executed.\n", len(rows))
		os.Exit(1)
	}

	applied := false
	if *apply && len(ops) > 0 {
		needTrash := false
		for _, op := range ops {
			if op.Action == "retire" {
				needTrash = true
			}
		}
		if needTrash {
			if err := os.MkdirAll(trashAbs, 0o755); err != nil {
				fail("creating trash directory %s: %v", trashAbs, err)
			}
		}
		if err := execute(ops); err != nil {
			fail("run failed and was rolled back, nothing changed: %v", err)
		}
		applied = true
		if err := writeJournal(journalAbs, rootAbs, planAbs, trashAbs, ops); err != nil {
			fail("operations succeeded but the journal could not be written to %s: %v", journalAbs, err)
		}
	}

	if *asJSON {
		emitJSON(report{
			Tool: "filedeck", Version: toolVersion, Command: "apply",
			Root: rootAbs, Plan: planAbs, Journal: journalAbs, Trash: trashAbs,
			OK: true, DryRun: !*apply, Applied: applied, Rows: len(rows),
			Operations: len(ops), TotalBytes: total, TotalHuman: humanBytes(total),
			Ops: ops,
		})
		os.Exit(0)
	}
	if applied {
		fmt.Println("APPLIED - plan executed:")
	} else {
		fmt.Println("DRY RUN - nothing was changed. Re-run with --apply to commit.")
	}
	for _, op := range ops {
		fmt.Printf("  row %-3d %-6s %s -> %s\n", op.Row, op.Action, op.FromRel, op.ToRel)
	}
	fmt.Printf("%d row(s), %d operation(s) %s, %s total\n", len(rows), len(ops), verb(applied), humanBytes(total))
	if applied {
		fmt.Printf("undo journal: %s\n", journalAbs)
		fmt.Printf("to reverse:   filedeck undo --journal %s --apply\n", journalAbs)
	}
}

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

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", "", "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, "filedeck: undo takes no positional arguments (got %v)\n\n", fs.Args())
		usage()
		os.Exit(1)
	}
	if *journal == "" {
		fmt.Fprintln(os.Stderr, "filedeck: undo requires --journal <file>")
		fmt.Fprintln(os.Stderr, "")
		usage()
		os.Exit(1)
	}
	journalAbs := mustAbs("--journal", *journal)
	jf, err := readJournal(journalAbs)
	if err != nil {
		fail("%v", err)
	}
	trash := jf.Trash
	if trash == "" {
		trash = filepath.Join(jf.Root, ".filedeck-trash")
	}

	// Every path the reversal vacates is available to another reversal step,
	// because the executor parks all sources before landing any destination.
	vacated := make(map[string]bool)
	for _, jo := range jf.Ops {
		if _, err := os.Lstat(jo.To); err == nil {
			vacated[jo.To] = true
		}
	}

	// Reverse order: the last operation performed is the first one undone.
	var ops []operation
	var probs []problem
	taken := make(map[string]bool)
	for i := len(jf.Ops) - 1; i >= 0; i-- {
		jo := jf.Ops[i]
		fi, err := os.Lstat(jo.To)
		if err != nil {
			probs = append(probs, problem{
				Row: jo.Row, Code: "undo_target_missing", Action: jo.Action,
				Source: jo.To, Destination: jo.From,
				Message: fmt.Sprintf("%s recorded %s but it is no longer there", jo.Action, jo.To),
			})
			continue
		}
		back := jo.From
		if jo.Action == "copy" {
			// The copy created a new file. Undo parks it in the trash; filedeck
			// never hard-deletes anything.
			back = trashTarget(trash, relTo(jf.Root, jo.To), taken)
			taken[back] = true
		} else if _, err := os.Lstat(back); err == nil && !vacated[back] {
			probs = append(probs, problem{
				Row: jo.Row, Code: "undo_origin_occupied", Action: jo.Action,
				Source: jo.To, Destination: jo.From,
				Message: fmt.Sprintf("cannot restore %s: something already occupies it", jo.From),
			})
			continue
		}
		ops = append(ops, operation{
			Row: jo.Row, Action: "undo-" + jo.Action,
			From: jo.To, To: back,
			FromRel: shortPath(jf.Root, trash, jo.To), ToRel: shortPath(jf.Root, trash, back),
			Bytes: fi.Size(), Human: humanBytes(fi.Size()),
		})
	}

	var total int64
	for _, op := range ops {
		total += op.Bytes
	}
	if len(probs) > 0 {
		if *asJSON {
			emitJSON(report{
				Tool: "filedeck", Version: toolVersion, Command: "undo",
				Root: jf.Root, Journal: journalAbs, Trash: trash,
				OK: false, DryRun: !*apply, Rows: len(jf.Ops), Operations: len(ops),
				TotalBytes: total, TotalHuman: humanBytes(total), Problems: probs,
			})
		}
		reportProblems(probs, "journal "+filepath.Base(journalAbs))
		os.Exit(1)
	}

	applied := false
	if *apply && len(ops) > 0 {
		for _, op := range ops {
			if strings.HasSuffix(op.Action, "copy") {
				if err := os.MkdirAll(trash, 0o755); err != nil {
					fail("creating trash directory %s: %v", trash, err)
				}
				break
			}
		}
		if err := execute(undoAsMoves(ops)); err != nil {
			fail("undo failed and was rolled back, nothing changed: %v", err)
		}
		applied = true
	}

	if *asJSON {
		emitJSON(report{
			Tool: "filedeck", Version: toolVersion, Command: "undo",
			Root: jf.Root, Journal: journalAbs, Trash: trash,
			OK: true, DryRun: !*apply, Applied: applied, Rows: len(jf.Ops),
			Operations: len(ops), TotalBytes: total, TotalHuman: humanBytes(total),
			Ops: ops,
		})
		os.Exit(0)
	}
	if applied {
		fmt.Println("APPLIED - plan reversed:")
	} else {
		fmt.Println("DRY RUN - nothing was changed. Re-run with --apply to commit.")
	}
	for _, op := range ops {
		fmt.Printf("  row %-3d %-11s %s -> %s\n", op.Row, op.Action, op.FromRel, op.ToRel)
	}
	fmt.Printf("%d journal entr(ies), %d reversal(s) %s, %s total\n",
		len(jf.Ops), len(ops), verb(applied), humanBytes(total))
	if applied {
		fmt.Printf("journal %s has now been replayed backwards.\n", journalAbs)
	}
}

// undoAsMoves relabels reversal steps so the executor treats every one of them
// as a move (copies made by the original run are moved into the trash).
func undoAsMoves(ops []operation) []operation {
	out := make([]operation, len(ops))
	copy(out, ops)
	for i := range out {
		out[i].Action = "move"
	}
	return out
}
