// DupePilot — exact-duplicate file finder with a safe quarantine mode.
//
// Usage:
//
//	dupepilot scan <dir> [<dir> ...]              report duplicate sets
//	dupepilot scan <dir> --json                   machine-readable report
//	dupepilot quarantine <dir> --to <qdir>         move all-but-one copy of each
//	                                               duplicate set into <qdir>,
//	                                               preserving the original's
//	                                               relative path
//
// DupePilot never deletes anything: quarantine mode moves files, it does
// not remove them, so a mistaken match is always one `mv` away from undone.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

type fileInfo struct {
	Path    string
	Size    int64
	ModTime int64
}

type dupeSet struct {
	Hash        string   `json:"hash"`
	Size        int64    `json:"size"`
	Files       []string `json:"files"`
	Reclaimable int64    `json:"reclaimable_bytes"`
}

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question 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 "scan":
		cmdScan(os.Args[2:])
	case "quarantine":
		cmdQuarantine(os.Args[2:])
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

// reorderFlags moves recognized --flag / --flag value pairs to the front of
// args and everything else to the back, because Go's flag package stops
// parsing at the first positional argument and this CLI's directory
// arguments legitimately come before flags on the command line.
// valueFlags maps flag name (without leading dashes) -> true if it consumes
// a following value.
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 usage() {
	fmt.Fprint(os.Stderr, `DupePilot — exact-duplicate file finder

Usage:
  dupepilot scan <dir> [<dir> ...] [--json] [--min-size BYTES]
  dupepilot quarantine <dir> --to <qdir> [--min-size BYTES] [--apply]

"quarantine" without --apply prints what it WOULD move (dry run).
`)
}

func cmdScan(args []string) {
	fs := flag.NewFlagSet("scan", flag.ExitOnError)
	jsonOut := fs.Bool("json", false, "print machine-readable JSON")
	minSize := fs.Int64("min-size", 1, "ignore files smaller than this many bytes")
	fs.Parse(reorderFlags(args, map[string]bool{"min-size": true}))
	dirs := fs.Args()
	if len(dirs) == 0 {
		fmt.Fprintln(os.Stderr, "scan requires at least one directory")
		os.Exit(1)
	}

	sets, total, err := findDuplicates(dirs, *minSize)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	if *jsonOut {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(map[string]any{
			"duplicate_sets":      sets,
			"reclaimable_bytes":   total,
			"duplicate_set_count": len(sets),
		})
		return
	}

	if len(sets) == 0 {
		fmt.Println("No exact duplicates found.")
		return
	}
	for i, s := range sets {
		fmt.Printf("Set %d — %s each, %d copies (reclaim %s)\n", i+1, humanBytes(s.Size), len(s.Files), humanBytes(s.Reclaimable))
		for _, f := range s.Files {
			fmt.Printf("    %s\n", f)
		}
	}
	fmt.Printf("\n%d duplicate set(s), %s reclaimable\n", len(sets), humanBytes(total))
}

func cmdQuarantine(args []string) {
	fs := flag.NewFlagSet("quarantine", flag.ExitOnError)
	to := fs.String("to", "", "quarantine directory (required)")
	minSize := fs.Int64("min-size", 1, "ignore files smaller than this many bytes")
	apply := fs.Bool("apply", false, "actually move files (default is dry run)")
	fs.Parse(reorderFlags(args, map[string]bool{"to": true, "min-size": true}))
	dirs := fs.Args()
	if len(dirs) == 0 || *to == "" {
		fmt.Fprintln(os.Stderr, "usage: dupepilot quarantine <dir> --to <qdir> [--apply]")
		os.Exit(1)
	}

	sets, total, err := findDuplicates(dirs, *minSize)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
	if len(sets) == 0 {
		fmt.Println("No exact duplicates found. Nothing to quarantine.")
		return
	}

	root := dirs[0]
	moved := 0
	var movedBytes int64
	for _, s := range sets {
		// Keep the first file (oldest on disk in scan order), quarantine the rest.
		for _, f := range s.Files[1:] {
			rel, err := filepath.Rel(root, f)
			if err != nil {
				rel = filepath.Base(f)
			}
			dest := filepath.Join(*to, rel)
			if *apply {
				if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
					fmt.Fprintf(os.Stderr, "  failed to prep %s: %v\n", dest, err)
					continue
				}
				if err := os.Rename(f, dest); err != nil {
					fmt.Fprintf(os.Stderr, "  failed to move %s: %v\n", f, err)
					continue
				}
				fmt.Printf("  moved   %s -> %s\n", f, dest)
			} else {
				fmt.Printf("  would move   %s -> %s\n", f, dest)
			}
			moved++
			movedBytes += s.Size
		}
	}

	verb := "would free"
	if *apply {
		verb = "freed"
	}
	fmt.Printf("\n%d file(s) %s, %s %s\n", moved, map[bool]string{true: "moved", false: "flagged"}[*apply], verb, humanBytes(movedBytes))
	if !*apply {
		fmt.Println("(dry run — re-run with --apply to actually move files)")
	}
	_ = total
}

// findDuplicates walks the given directories, groups files by size (cheap
// pre-filter), then hashes only files that share a size with at least one
// other file. Returns duplicate sets sorted by reclaimable space descending.
func findDuplicates(dirs []string, minSize int64) ([]dupeSet, int64, error) {
	bySize := map[int64][]fileInfo{}
	for _, d := range dirs {
		err := filepath.Walk(d, func(path string, info os.FileInfo, err error) error {
			if err != nil {
				return nil // skip unreadable entries, keep going
			}
			if info.IsDir() || !info.Mode().IsRegular() {
				return nil
			}
			if info.Size() < minSize {
				return nil
			}
			bySize[info.Size()] = append(bySize[info.Size()], fileInfo{
				Path: path, Size: info.Size(), ModTime: info.ModTime().Unix(),
			})
			return nil
		})
		if err != nil {
			return nil, 0, err
		}
	}

	var sets []dupeSet
	var total int64
	for size, files := range bySize {
		if len(files) < 2 {
			continue
		}
		byHash := map[string][]fileInfo{}
		for _, f := range files {
			h, err := hashFile(f.Path)
			if err != nil {
				continue
			}
			byHash[h] = append(byHash[h], f)
		}
		for hash, group := range byHash {
			if len(group) < 2 {
				continue
			}
			sort.Slice(group, func(i, j int) bool { return group[i].ModTime < group[j].ModTime })
			paths := make([]string, len(group))
			for i, g := range group {
				paths[i] = g.Path
			}
			reclaim := size * int64(len(group)-1)
			total += reclaim
			sets = append(sets, dupeSet{Hash: hash, Size: size, Files: paths, Reclaimable: reclaim})
		}
	}
	sort.Slice(sets, func(i, j int) bool { return sets[i].Reclaimable > sets[j].Reclaimable })
	return sets, total, nil
}

func hashFile(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

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])
}
