// DuplicateDeck — near-duplicate file finder that clusters by FILENAME
// similarity (Levenshtein edit distance) rather than by file contents.
package main

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

const version = "1.0.0"

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// Levenshtein edit distance (classic DP, two-row space optimization)
// ---------------------------------------------------------------------------

func levenshtein(a, b string) int {
	ra := []rune(a)
	rb := []rune(b)
	if len(ra) == 0 {
		return len(rb)
	}
	if len(rb) == 0 {
		return len(ra)
	}
	// prev = row for i-1, cur = row for i. Each row has len(rb)+1 cells.
	prev := make([]int, len(rb)+1)
	cur := make([]int, len(rb)+1)
	for j := 0; j <= len(rb); j++ {
		prev[j] = j
	}
	for i := 1; i <= len(ra); i++ {
		cur[0] = i
		for j := 1; j <= len(rb); j++ {
			cost := 1
			if ra[i-1] == rb[j-1] {
				cost = 0
			}
			del := prev[j] + 1
			ins := cur[j-1] + 1
			sub := prev[j-1] + cost
			m := del
			if ins < m {
				m = ins
			}
			if sub < m {
				m = sub
			}
			cur[j] = m
		}
		prev, cur = cur, prev
	}
	return prev[len(rb)]
}

// ---------------------------------------------------------------------------
// Filename normalization
// ---------------------------------------------------------------------------

var (
	reParenNum = regexp.MustCompile(`[\s._\-]*[\(\[]\s*(\d{1,4})\s*[\)\]]$`)
	reCopyWord = regexp.MustCompile(`[\s._\-]*copy(?:\s*[\(\[]?\s*(\d{1,4})\s*[\)\]]?)?$`)
	reMarkWord = regexp.MustCompile(`[\s._\-]*(final|finalversion|draft|new|old|orig|original|edited|dup|duplicate|backup|bak|v\d{1,3})$`)
	reCopyPre  = regexp.MustCompile(`^copy[\s._\-]+of[\s._\-]+`)
	reMarkOnly = regexp.MustCompile(`^[\s._\-]*$`)
)

// collapse keeps Unicode letters and digits (so "résumé" and "写真" survive
// intact) and replaces every other run of characters with a single space.
func collapse(s string) string {
	var b strings.Builder
	space := false
	for _, r := range s {
		if unicode.IsLetter(r) || unicode.IsDigit(r) {
			if space && b.Len() > 0 {
				b.WriteRune(' ')
			}
			space = false
			b.WriteRune(r)
			continue
		}
		space = true
	}
	return b.String()
}

// normalize lowercases the name, strips the extension, pulls off common
// copy-markers and collapses whitespace/punctuation. It returns the stem plus
// the list of copy-markers that were removed (each marker counts as one edit
// when scoring, see similarity()).
func normalize(name string) (stem string, markers []string) {
	base := filepath.Base(name)
	ext := filepath.Ext(base)
	if ext != "" && ext != base {
		base = strings.TrimSuffix(base, ext)
	}
	s := strings.ToLower(strings.TrimSpace(base))

	if m := reCopyPre.FindString(s); m != "" {
		s = s[len(m):]
		markers = append(markers, "copy")
	}

	for i := 0; i < 8; i++ {
		if m := reParenNum.FindStringSubmatch(s); m != nil {
			s = s[:len(s)-len(m[0])]
			markers = append(markers, m[1])
			continue
		}
		if m := reCopyWord.FindStringSubmatch(s); m != nil {
			s = s[:len(s)-len(m[0])]
			if m[1] != "" {
				markers = append(markers, "copy"+m[1])
			} else {
				markers = append(markers, "copy")
			}
			continue
		}
		if m := reMarkWord.FindStringSubmatch(s); m != nil {
			// Never strip the whole name away ("final.txt" stays "final").
			rest := s[:len(s)-len(m[0])]
			if reMarkOnly.MatchString(rest) {
				break
			}
			s = rest
			markers = append(markers, m[1])
			continue
		}
		break
	}

	stem = collapse(s)
	sort.Strings(markers)
	return stem, markers
}

// markerDiff is the size of the symmetric difference of two marker multisets.
func markerDiff(a, b []string) int {
	counts := map[string]int{}
	for _, m := range a {
		counts[m]++
	}
	for _, m := range b {
		counts[m]--
	}
	d := 0
	for _, v := range counts {
		if v < 0 {
			v = -v
		}
		d += v
	}
	return d
}

// normLen is the scoring length of a name: stem runes plus one unit per marker.
func normLen(stem string, markers []string) int {
	return len([]rune(stem)) + len(markers)
}

// similarity scores two normalized names in [0,1].
//
//	distance   = levenshtein(stemA, stemB) + |markersA symmetric-diff markersB|
//	similarity = 1 - distance / max(normLenA, normLenB)
//
// Each stripped copy-marker costs exactly one edit, so "invoice" and
// "invoice (1)" are close but not identical — which keeps --min-similarity a
// meaningful dial rather than an on/off switch.
func similarity(stemA string, markA []string, stemB string, markB []string) float64 {
	la := normLen(stemA, markA)
	lb := normLen(stemB, markB)
	maxLen := la
	if lb > maxLen {
		maxLen = lb
	}
	if maxLen == 0 {
		return 1
	}
	d := levenshtein(stemA, stemB) + markerDiff(markA, markB)
	s := 1 - float64(d)/float64(maxLen)
	if s < 0 {
		return 0
	}
	return s
}

// ---------------------------------------------------------------------------
// Scanning
// ---------------------------------------------------------------------------

type fileRec struct {
	Path    string
	Rel     string
	Size    int64
	Mod     time.Time
	Stem    string
	Markers []string
}

func scanTree(root string, skipDirs []string) ([]fileRec, []string, error) {
	var recs []fileRec
	var warnings []string
	skip := map[string]bool{}
	for _, d := range skipDirs {
		if d != "" {
			skip[filepath.Clean(d)] = true
		}
	}
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			warnings = append(warnings, fmt.Sprintf("%s: %v", path, err))
			if d != nil && d.IsDir() {
				return fs.SkipDir
			}
			return nil
		}
		if d.IsDir() {
			if skip[filepath.Clean(path)] {
				return fs.SkipDir
			}
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			warnings = append(warnings, fmt.Sprintf("%s: %v", path, ierr))
			return nil
		}
		rel, rerr := filepath.Rel(root, path)
		if rerr != nil {
			rel = path
		}
		stem, markers := normalize(path)
		recs = append(recs, fileRec{
			Path: path, Rel: rel, Size: info.Size(), Mod: info.ModTime(),
			Stem: stem, Markers: markers,
		})
		return nil
	})
	if err != nil {
		return nil, warnings, err
	}
	sort.Slice(recs, func(i, j int) bool { return recs[i].Rel < recs[j].Rel })
	return recs, warnings, nil
}

// ---------------------------------------------------------------------------
// Clustering (union-find over the similarity graph)
// ---------------------------------------------------------------------------

type unionFind struct{ parent []int }

func newUnionFind(n int) *unionFind {
	u := &unionFind{parent: make([]int, n)}
	for i := range u.parent {
		u.parent[i] = i
	}
	return u
}

func (u *unionFind) find(x int) int {
	for u.parent[x] != x {
		u.parent[x] = u.parent[u.parent[x]]
		x = u.parent[x]
	}
	return x
}

func (u *unionFind) union(a, b int) {
	ra, rb := u.find(a), u.find(b)
	if ra != rb {
		u.parent[rb] = ra
	}
}

type cluster struct {
	Stem        string
	Members     []fileRec
	KeeperIdx   int
	Reclaimable int64
}

func buildClusters(recs []fileRec, threshold float64) []cluster {
	n := len(recs)
	uf := newUnionFind(n)
	for i := 0; i < n; i++ {
		li := normLen(recs[i].Stem, recs[i].Markers)
		for j := i + 1; j < n; j++ {
			lj := normLen(recs[j].Stem, recs[j].Markers)
			maxLen, minLen := li, lj
			if lj > maxLen {
				maxLen, minLen = lj, li
			}
			// Cheap bound: the length gap alone is a lower bound on the
			// edit distance, so skip pairs that cannot reach the threshold.
			if maxLen > 0 && 1-float64(maxLen-minLen)/float64(maxLen) < threshold {
				continue
			}
			if similarity(recs[i].Stem, recs[i].Markers, recs[j].Stem, recs[j].Markers) >= threshold {
				uf.union(i, j)
			}
		}
	}
	groups := map[int][]int{}
	for i := 0; i < n; i++ {
		r := uf.find(i)
		groups[r] = append(groups[r], i)
	}
	var out []cluster
	for _, idxs := range groups {
		if len(idxs) < 2 {
			continue
		}
		c := cluster{}
		for _, i := range idxs {
			c.Members = append(c.Members, recs[i])
		}
		sort.Slice(c.Members, func(a, b int) bool { return c.Members[a].Rel < c.Members[b].Rel })
		c.KeeperIdx = pickKeeper(c.Members)
		c.Stem = c.Members[c.KeeperIdx].Stem
		for i, m := range c.Members {
			if i != c.KeeperIdx {
				c.Reclaimable += m.Size
			}
		}
		out = append(out, c)
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Reclaimable != out[j].Reclaimable {
			return out[i].Reclaimable > out[j].Reclaimable
		}
		if out[i].Stem != out[j].Stem {
			return out[i].Stem < out[j].Stem
		}
		return out[i].Members[0].Rel < out[j].Members[0].Rel
	})
	return out
}

// pickKeeper: largest file wins; newest mtime breaks ties; then path order.
func pickKeeper(members []fileRec) int {
	best := 0
	for i := 1; i < len(members); i++ {
		m, b := members[i], members[best]
		switch {
		case m.Size != b.Size:
			if m.Size > b.Size {
				best = i
			}
		case !m.Mod.Equal(b.Mod):
			if m.Mod.After(b.Mod) {
				best = i
			}
		default:
			if m.Rel < b.Rel {
				best = i
			}
		}
	}
	return best
}

// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------

type jsonMember struct {
	Path        string   `json:"path"`
	Rel         string   `json:"rel"`
	Size        int64    `json:"size_bytes"`
	SizeHuman   string   `json:"size_human"`
	Modified    string   `json:"modified"`
	Stem        string   `json:"stem"`
	Markers     []string `json:"markers"`
	Keeper      bool     `json:"keeper"`
	SimToKeeper float64  `json:"similarity_to_keeper"`
}

type jsonCluster struct {
	ID          int          `json:"id"`
	Stem        string       `json:"stem"`
	Count       int          `json:"count"`
	Reclaimable int64        `json:"reclaimable_bytes"`
	Members     []jsonMember `json:"members"`
}

type jsonScan struct {
	Tool          string        `json:"tool"`
	Version       string        `json:"version"`
	Command       string        `json:"command"`
	Root          string        `json:"root"`
	MinSimilarity float64       `json:"min_similarity"`
	FilesScanned  int           `json:"files_scanned"`
	ClusterCount  int           `json:"cluster_count"`
	Reclaimable   int64         `json:"reclaimable_bytes"`
	Clusters      []jsonCluster `json:"clusters"`
	Warnings      []string      `json:"warnings"`
}

func toJSONClusters(cs []cluster) ([]jsonCluster, int64) {
	out := make([]jsonCluster, 0, len(cs))
	var total int64
	for i, c := range cs {
		keeper := c.Members[c.KeeperIdx]
		jc := jsonCluster{ID: i + 1, Stem: c.Stem, Count: len(c.Members), Reclaimable: c.Reclaimable}
		for mi, m := range c.Members {
			mk := m.Markers
			if mk == nil {
				mk = []string{}
			}
			jc.Members = append(jc.Members, jsonMember{
				Path: m.Path, Rel: m.Rel, Size: m.Size, SizeHuman: humanBytes(m.Size),
				Modified: m.Mod.Format(time.RFC3339), Stem: m.Stem, Markers: mk,
				Keeper:      mi == c.KeeperIdx,
				SimToKeeper: round3(similarity(m.Stem, m.Markers, keeper.Stem, keeper.Markers)),
			})
		}
		total += c.Reclaimable
		out = append(out, jc)
	}
	return out, total
}

func round3(f float64) float64 {
	return float64(int(f*1000+0.5)) / 1000
}

func printClusters(w io.Writer, cs []cluster) {
	for i, c := range cs {
		keeper := c.Members[c.KeeperIdx]
		fmt.Fprintf(w, "Cluster %d  stem %q  %d files  reclaimable %s\n",
			i+1, c.Stem, len(c.Members), humanBytes(c.Reclaimable))
		for mi, m := range c.Members {
			tag := "dup   "
			if mi == c.KeeperIdx {
				tag = "KEEPER"
			}
			sim := similarity(m.Stem, m.Markers, keeper.Stem, keeper.Markers)
			fmt.Fprintf(w, "  %s  %9s  %s  sim %.2f  %s\n",
				tag, humanBytes(m.Size), m.Mod.Format("2006-01-02 15:04"), sim, m.Rel)
		}
		fmt.Fprintln(w)
	}
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func usage() {
	w := os.Stderr
	fmt.Fprintf(w, `DuplicateDeck %s — find near-duplicate files by FILENAME similarity.

Content-hash dedupers miss "invoice.pdf / invoice (1).pdf / invoice_final.pdf"
families because the bytes differ. DuplicateDeck compares normalized filenames
with a real Levenshtein edit distance instead.

Usage:
  duplicatedeck scan <dir> [--min-similarity 0.8] [--json]
  duplicatedeck quarantine <dir> --quarantine <qdir> [--min-similarity 0.8] [--apply]
  duplicatedeck compare <nameA> <nameB> [--json]
  duplicatedeck help | -h | --help

Commands:
  scan        Walk <dir> recursively, cluster files whose normalized names are
              within the similarity threshold, and mark a suggested KEEPER
              (largest file; newest mtime breaks ties).
  quarantine  Move every non-keeper cluster member into <qdir>, preserving the
              path relative to <dir>. DRY RUN unless --apply is given.
              Files are MOVED, never deleted.
  compare     Show the normalization, raw Levenshtein distance and similarity
              of two names. Useful for choosing --min-similarity.

Flags:
  --min-similarity F   Cluster threshold in [0,1] (default 0.80).
  --quarantine DIR     Destination directory for the quarantine command.
  --apply              Actually move files. Without it, quarantine is a dry run.
  --json               Machine-readable JSON output.

Exit codes:
  0  success (including "nothing found")
  1  bad invocation or a fatal error
`, version)
}

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

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// 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 args[0] {
	case "help", "-h", "--help":
		usage()
		os.Exit(0)
	case "version", "--version":
		fmt.Println("duplicatedeck " + version)
		os.Exit(0)
	case "scan":
		cmdScan(args[1:])
	case "quarantine":
		cmdQuarantine(args[1:])
	case "compare":
		cmdCompare(args[1:])
	default:
		if strings.HasPrefix(args[0], "-") {
			// e.g. `duplicatedeck --help scan`
			for _, a := range args {
				if a == "-h" || a == "--help" {
					usage()
					os.Exit(0)
				}
			}
		}
		fmt.Fprintf(os.Stderr, "duplicatedeck: unknown command %q\n\n", args[0])
		usage()
		os.Exit(1)
	}
}

func checkHelp(args []string) {
	for _, a := range args {
		if a == "-h" || a == "--help" || a == "help" {
			usage()
			os.Exit(0)
		}
	}
}

func validThreshold(t float64) {
	if t < 0 || t > 1 {
		fmt.Fprintf(os.Stderr, "duplicatedeck: --min-similarity must be between 0 and 1 (got %g)\n\n", t)
		usage()
		os.Exit(1)
	}
}

func cmdScan(argv []string) {
	checkHelp(argv)
	fset := flag.NewFlagSet("scan", flag.ContinueOnError)
	fset.SetOutput(os.Stderr)
	fset.Usage = usage
	minSim := fset.Float64("min-similarity", 0.8, "similarity threshold 0..1")
	asJSON := fset.Bool("json", false, "JSON output")
	if err := fset.Parse(reorderFlags(argv, map[string]bool{"min-similarity": true})); err != nil {
		usage()
		os.Exit(1)
	}
	rest := fset.Args()
	if len(rest) != 1 {
		fmt.Fprintln(os.Stderr, "duplicatedeck: scan needs exactly one <dir>")
		usage()
		os.Exit(1)
	}
	validThreshold(*minSim)

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

	recs, warnings, err := scanTree(root, nil)
	if err != nil {
		fail("%v", err)
	}
	clusters := buildClusters(recs, *minSim)

	if *asJSON {
		jcs, total := toJSONClusters(clusters)
		if jcs == nil {
			jcs = []jsonCluster{}
		}
		if warnings == nil {
			warnings = []string{}
		}
		out := jsonScan{
			Tool: "duplicatedeck", Version: version, Command: "scan", Root: root,
			MinSimilarity: *minSim, FilesScanned: len(recs), ClusterCount: len(clusters),
			Reclaimable: total, Clusters: jcs, Warnings: warnings,
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("%v", err)
		}
		return
	}

	for _, w := range warnings {
		fmt.Fprintln(os.Stderr, "warning: "+w)
	}
	fmt.Printf("DuplicateDeck scan: %s\n", root)
	fmt.Printf("Files scanned: %d   Threshold: %.2f\n\n", len(recs), *minSim)
	if len(recs) == 0 {
		fmt.Println("Directory is empty — nothing to compare.")
		return
	}
	if len(clusters) == 0 {
		fmt.Printf("No name-similar clusters found at similarity >= %.2f.\n", *minSim)
		return
	}
	printClusters(os.Stdout, clusters)
	var total int64
	for _, c := range clusters {
		total += c.Reclaimable
	}
	fmt.Printf("%d cluster(s); %s reclaimable if non-keepers are quarantined.\n",
		len(clusters), humanBytes(total))
	fmt.Println("Next: duplicatedeck quarantine <dir> --quarantine <qdir>   (dry run; add --apply to move)")
}

type moveOp struct {
	From string `json:"from"`
	To   string `json:"to"`
	Size int64  `json:"size_bytes"`
}

type jsonQuarantine struct {
	Tool          string        `json:"tool"`
	Version       string        `json:"version"`
	Command       string        `json:"command"`
	Root          string        `json:"root"`
	Quarantine    string        `json:"quarantine_dir"`
	MinSimilarity float64       `json:"min_similarity"`
	Applied       bool          `json:"applied"`
	DryRun        bool          `json:"dry_run"`
	FilesScanned  int           `json:"files_scanned"`
	ClusterCount  int           `json:"cluster_count"`
	Moves         []moveOp      `json:"moves"`
	MovedBytes    int64         `json:"moved_bytes"`
	Errors        []string      `json:"errors"`
	Clusters      []jsonCluster `json:"clusters"`
}

func cmdQuarantine(argv []string) {
	checkHelp(argv)
	fset := flag.NewFlagSet("quarantine", flag.ContinueOnError)
	fset.SetOutput(os.Stderr)
	fset.Usage = usage
	minSim := fset.Float64("min-similarity", 0.8, "similarity threshold 0..1")
	qdirFlag := fset.String("quarantine", "", "quarantine directory")
	apply := fset.Bool("apply", false, "actually move files")
	asJSON := fset.Bool("json", false, "JSON output")
	valueFlags := map[string]bool{"min-similarity": true, "quarantine": true}
	if err := fset.Parse(reorderFlags(argv, valueFlags)); err != nil {
		usage()
		os.Exit(1)
	}
	rest := fset.Args()
	if len(rest) != 1 {
		fmt.Fprintln(os.Stderr, "duplicatedeck: quarantine needs exactly one <dir>")
		usage()
		os.Exit(1)
	}
	if strings.TrimSpace(*qdirFlag) == "" {
		fmt.Fprintln(os.Stderr, "duplicatedeck: --quarantine <qdir> is required")
		usage()
		os.Exit(1)
	}
	validThreshold(*minSim)

	root, err := filepath.Abs(rest[0])
	if err != nil {
		fail("%v", err)
	}
	qdir, err := filepath.Abs(*qdirFlag)
	if err != nil {
		fail("%v", err)
	}
	st, err := os.Stat(root)
	if err != nil {
		fail("%v", err)
	}
	if !st.IsDir() {
		fail("%s is not a directory", root)
	}
	if qdir == root {
		fail("--quarantine must not be the scanned directory itself")
	}

	recs, warnings, err := scanTree(root, []string{qdir})
	if err != nil {
		fail("%v", err)
	}
	for _, w := range warnings {
		fmt.Fprintln(os.Stderr, "warning: "+w)
	}
	clusters := buildClusters(recs, *minSim)

	var moves []moveOp
	var moveErrs []string
	var movedBytes int64
	used := map[string]bool{}
	for _, c := range clusters {
		for mi, m := range c.Members {
			if mi == c.KeeperIdx {
				continue
			}
			dst := uniqueDest(filepath.Join(qdir, m.Rel), used)
			used[dst] = true
			moves = append(moves, moveOp{From: m.Path, To: dst, Size: m.Size})
		}
	}

	if *apply {
		var done []moveOp
		for _, mv := range moves {
			if err := moveFile(mv.From, mv.To); err != nil {
				moveErrs = append(moveErrs, fmt.Sprintf("%s: %v", mv.From, err))
				continue
			}
			movedBytes += mv.Size
			done = append(done, mv)
		}
		moves = done
	} else {
		for _, mv := range moves {
			movedBytes += mv.Size
		}
	}

	if *asJSON {
		jcs, _ := toJSONClusters(clusters)
		if jcs == nil {
			jcs = []jsonCluster{}
		}
		if moves == nil {
			moves = []moveOp{}
		}
		if moveErrs == nil {
			moveErrs = []string{}
		}
		out := jsonQuarantine{
			Tool: "duplicatedeck", Version: version, Command: "quarantine",
			Root: root, Quarantine: qdir, MinSimilarity: *minSim,
			Applied: *apply, DryRun: !*apply, FilesScanned: len(recs),
			ClusterCount: len(clusters), Moves: moves, MovedBytes: movedBytes,
			Errors: moveErrs, Clusters: jcs,
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("%v", err)
		}
		if len(moveErrs) > 0 {
			os.Exit(1)
		}
		return
	}

	if *apply {
		fmt.Println("DuplicateDeck quarantine (APPLY — files are being moved)")
	} else {
		fmt.Println("DuplicateDeck quarantine (DRY RUN — no files will be touched)")
	}
	fmt.Printf("Root:       %s\n", root)
	fmt.Printf("Quarantine: %s\n", qdir)
	fmt.Printf("Files scanned: %d   Threshold: %.2f\n\n", len(recs), *minSim)

	if len(clusters) == 0 {
		fmt.Printf("No name-similar clusters found at similarity >= %.2f. Nothing to quarantine.\n", *minSim)
		return
	}
	for i, c := range clusters {
		keeper := c.Members[c.KeeperIdx]
		fmt.Printf("Cluster %d  stem %q  keeper: %s (%s)\n", i+1, c.Stem, keeper.Rel, humanBytes(keeper.Size))
		for mi, m := range c.Members {
			if mi == c.KeeperIdx {
				continue
			}
			verb := "would move"
			if *apply {
				verb = "moved     "
			}
			dst := filepath.Join(qdir, m.Rel)
			for _, mv := range moves {
				if mv.From == m.Path {
					dst = mv.To
				}
			}
			fmt.Printf("  %s  %s  ->  %s\n", verb, m.Rel, dst)
		}
		fmt.Println()
	}
	for _, e := range moveErrs {
		fmt.Fprintln(os.Stderr, "error: "+e)
	}
	if *apply {
		fmt.Printf("Moved %d file(s), %s. Keepers and unrelated files were left in place.\n",
			len(moves), humanBytes(movedBytes))
		fmt.Println("Nothing was deleted — undo by moving files back from the quarantine directory.")
	} else {
		fmt.Printf("%d file(s), %s would be moved. NOTHING WAS MOVED.\n", len(moves), humanBytes(movedBytes))
		fmt.Println("Re-run with --apply to perform the move.")
	}
	if len(moveErrs) > 0 {
		os.Exit(1)
	}
}

func uniqueDest(dst string, used map[string]bool) string {
	candidate := dst
	ext := filepath.Ext(dst)
	stem := strings.TrimSuffix(dst, ext)
	for i := 1; ; i++ {
		_, err := os.Lstat(candidate)
		if err != nil && !used[candidate] {
			return candidate
		}
		candidate = fmt.Sprintf("%s~%d%s", stem, i, ext)
	}
}

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
	}
	// Cross-device or otherwise un-renameable: copy then remove the source.
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	info, err := in.Stat()
	if err != nil {
		return err
	}
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		os.Remove(dst)
		return err
	}
	if err := out.Close(); err != nil {
		os.Remove(dst)
		return err
	}
	_ = os.Chtimes(dst, time.Now(), info.ModTime())
	in.Close()
	return os.Remove(src)
}

type jsonCompare struct {
	Tool        string   `json:"tool"`
	Version     string   `json:"version"`
	Command     string   `json:"command"`
	A           string   `json:"a"`
	B           string   `json:"b"`
	StemA       string   `json:"stem_a"`
	StemB       string   `json:"stem_b"`
	MarkersA    []string `json:"markers_a"`
	MarkersB    []string `json:"markers_b"`
	Levenshtein int      `json:"levenshtein"`
	MarkerDiff  int      `json:"marker_diff"`
	Distance    int      `json:"effective_distance"`
	NormLen     int      `json:"normalized_length"`
	Similarity  float64  `json:"similarity"`
}

func cmdCompare(argv []string) {
	checkHelp(argv)
	fset := flag.NewFlagSet("compare", flag.ContinueOnError)
	fset.SetOutput(os.Stderr)
	fset.Usage = usage
	asJSON := fset.Bool("json", false, "JSON output")
	if err := fset.Parse(reorderFlags(argv, map[string]bool{})); err != nil {
		usage()
		os.Exit(1)
	}
	rest := fset.Args()
	if len(rest) != 2 {
		fmt.Fprintln(os.Stderr, "duplicatedeck: compare needs exactly two names")
		usage()
		os.Exit(1)
	}
	stemA, markA := normalize(rest[0])
	stemB, markB := normalize(rest[1])
	lev := levenshtein(stemA, stemB)
	md := markerDiff(markA, markB)
	la, lb := normLen(stemA, markA), normLen(stemB, markB)
	maxLen := la
	if lb > maxLen {
		maxLen = lb
	}
	sim := similarity(stemA, markA, stemB, markB)

	if *asJSON {
		if markA == nil {
			markA = []string{}
		}
		if markB == nil {
			markB = []string{}
		}
		out := jsonCompare{
			Tool: "duplicatedeck", Version: version, Command: "compare",
			A: rest[0], B: rest[1], StemA: stemA, StemB: stemB,
			MarkersA: markA, MarkersB: markB, Levenshtein: lev, MarkerDiff: md,
			Distance: lev + md, NormLen: maxLen, Similarity: round3(sim),
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("%v", err)
		}
		return
	}
	fmt.Printf("A: %s\n     stem %-24q markers %s\n", rest[0], stemA, fmtMarkers(markA))
	fmt.Printf("B: %s\n     stem %-24q markers %s\n", rest[1], stemB, fmtMarkers(markB))
	fmt.Printf("levenshtein(stem_a, stem_b) = %d\n", lev)
	fmt.Printf("marker difference           = %d\n", md)
	fmt.Printf("effective distance          = %d\n", lev+md)
	fmt.Printf("normalized length (max)     = %d\n", maxLen)
	fmt.Printf("similarity                  = %.4f\n", sim)
}

func fmtMarkers(m []string) string {
	if len(m) == 0 {
		return "(none)"
	}
	return "[" + strings.Join(m, " ") + "]"
}
