// AppSweep — leftover detection by snapshot diff.
//
// Snapshot the filesystem before installing an app, snapshot again after
// uninstalling it, and AppSweep reports exactly what the uninstaller
// orphaned. Pattern-based cleaners only find leftovers somebody wrote a
// pattern for; a diff finds all of them.
package main

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

const (
	appName    = "appsweep"
	appVersion = "1.0.0"
	schemaID   = "appsweep/snapshot/1"
)

// ---------------------------------------------------------------------------
// Shared Techlosoft CLI helpers (identical across the tool line).
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// Snapshot data model
// ---------------------------------------------------------------------------

// FileEntry is one regular file recorded in a snapshot.
type FileEntry struct {
	Path  string `json:"path"`  // absolute, cleaned
	Root  string `json:"root"`  // the snapshot root this file was found under
	Rel   string `json:"rel"`   // slash-separated path relative to Root
	Size  int64  `json:"size"`  // bytes
	MTime string `json:"mtime"` // RFC3339 with nanoseconds, UTC
}

// Snapshot is the on-disk record produced by `appsweep snapshot`.
type Snapshot struct {
	Schema  string      `json:"schema"`
	Tool    string      `json:"tool"`
	Version string      `json:"version"`
	Created string      `json:"created"`
	OS      string      `json:"os"`
	Host    string      `json:"host,omitempty"`
	Roots   []string    `json:"roots"`
	Files   []FileEntry `json:"files"`
}

// TotalBytes is the sum of the sizes of every file in the snapshot.
func (s *Snapshot) TotalBytes() int64 {
	var n int64
	for _, f := range s.Files {
		n += f.Size
	}
	return n
}

func (s *Snapshot) index() map[string]FileEntry {
	m := make(map[string]FileEntry, len(s.Files))
	for _, f := range s.Files {
		m[f.Path] = f
	}
	return m
}

// ---------------------------------------------------------------------------
// Diff data model
// ---------------------------------------------------------------------------

// ModEntry is a file present in both snapshots whose size or mtime changed.
type ModEntry struct {
	Path        string   `json:"path"`
	Root        string   `json:"root"`
	Rel         string   `json:"rel"`
	SizeBefore  int64    `json:"size_before"`
	SizeAfter   int64    `json:"size_after"`
	MTimeBefore string   `json:"mtime_before"`
	MTimeAfter  string   `json:"mtime_after"`
	Reasons     []string `json:"reasons"`
}

type sideInfo struct {
	File    string   `json:"file"`
	Created string   `json:"created"`
	Files   int      `json:"files"`
	Bytes   int64    `json:"bytes"`
	Roots   []string `json:"roots"`
}

type fileGroup struct {
	Count int         `json:"count"`
	Bytes int64       `json:"bytes"`
	Files []FileEntry `json:"files"`
}

type modGroup struct {
	Count int        `json:"count"`
	Bytes int64      `json:"bytes"`
	Files []ModEntry `json:"files"`
}

type unchangedGroup struct {
	Count int   `json:"count"`
	Bytes int64 `json:"bytes"`
}

// DiffResult is the complete comparison of two snapshots.
type DiffResult struct {
	Tool      string         `json:"tool"`
	Version   string         `json:"version"`
	Before    sideInfo       `json:"before"`
	After     sideInfo       `json:"after"`
	Added     fileGroup      `json:"added"`
	Removed   fileGroup      `json:"removed"`
	Modified  modGroup       `json:"modified"`
	Unchanged unchangedGroup `json:"unchanged"`
}

// Clean reports whether the two snapshots are identical in content.
func (d *DiffResult) Clean() bool {
	return d.Added.Count == 0 && d.Removed.Count == 0 && d.Modified.Count == 0
}

func mtimeEqual(a, b string) bool {
	if a == b {
		return true
	}
	ta, ea := time.Parse(time.RFC3339Nano, a)
	tb, eb := time.Parse(time.RFC3339Nano, b)
	if ea != nil || eb != nil {
		return false
	}
	return ta.Equal(tb)
}

func computeDiff(before, after *Snapshot, beforeFile, afterFile string) *DiffResult {
	bi := before.index()
	ai := after.index()

	d := &DiffResult{
		Tool:    appName,
		Version: appVersion,
		Before: sideInfo{
			File: beforeFile, Created: before.Created,
			Files: len(before.Files), Bytes: before.TotalBytes(), Roots: before.Roots,
		},
		After: sideInfo{
			File: afterFile, Created: after.Created,
			Files: len(after.Files), Bytes: after.TotalBytes(), Roots: after.Roots,
		},
	}
	d.Added.Files = []FileEntry{}
	d.Removed.Files = []FileEntry{}
	d.Modified.Files = []ModEntry{}

	for _, f := range after.Files {
		b, ok := bi[f.Path]
		if !ok {
			d.Added.Files = append(d.Added.Files, f)
			d.Added.Bytes += f.Size
			continue
		}
		var reasons []string
		if b.Size != f.Size {
			reasons = append(reasons, "size")
		}
		if !mtimeEqual(b.MTime, f.MTime) {
			reasons = append(reasons, "mtime")
		}
		if len(reasons) == 0 {
			d.Unchanged.Count++
			d.Unchanged.Bytes += f.Size
			continue
		}
		d.Modified.Files = append(d.Modified.Files, ModEntry{
			Path: f.Path, Root: f.Root, Rel: f.Rel,
			SizeBefore: b.Size, SizeAfter: f.Size,
			MTimeBefore: b.MTime, MTimeAfter: f.MTime,
			Reasons: reasons,
		})
		d.Modified.Bytes += f.Size
	}

	for _, f := range before.Files {
		if _, ok := ai[f.Path]; !ok {
			d.Removed.Files = append(d.Removed.Files, f)
			d.Removed.Bytes += f.Size
		}
	}

	sort.Slice(d.Added.Files, func(i, j int) bool { return d.Added.Files[i].Path < d.Added.Files[j].Path })
	sort.Slice(d.Removed.Files, func(i, j int) bool { return d.Removed.Files[i].Path < d.Removed.Files[j].Path })
	sort.Slice(d.Modified.Files, func(i, j int) bool { return d.Modified.Files[i].Path < d.Modified.Files[j].Path })

	d.Added.Count = len(d.Added.Files)
	d.Removed.Count = len(d.Removed.Files)
	d.Modified.Count = len(d.Modified.Files)
	return d
}

// ---------------------------------------------------------------------------
// Snapshot loading / saving
// ---------------------------------------------------------------------------

func loadSnapshot(path string) (*Snapshot, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil, fmt.Errorf("snapshot file not found: %s", path)
		}
		return nil, fmt.Errorf("read snapshot %s: %w", path, err)
	}
	if len(strings.TrimSpace(string(data))) == 0 {
		return nil, fmt.Errorf("snapshot %s is empty", path)
	}
	var s Snapshot
	if err := json.Unmarshal(data, &s); err != nil {
		return nil, fmt.Errorf("snapshot %s is not valid JSON (corrupt or truncated?): %v", path, err)
	}
	if s.Schema != schemaID {
		got := s.Schema
		if got == "" {
			got = "<missing>"
		}
		return nil, fmt.Errorf("snapshot %s: unrecognized schema %q (expected %q) — not an %s snapshot",
			path, got, schemaID, appName)
	}
	if s.Files == nil {
		return nil, fmt.Errorf("snapshot %s: missing \"files\" array (corrupt?)", path)
	}
	for i, f := range s.Files {
		if f.Path == "" {
			return nil, fmt.Errorf("snapshot %s: entry %d has an empty path (corrupt?)", path, i)
		}
	}
	return &s, nil
}

func writeJSON(path string, v any) error {
	data, err := json.MarshalIndent(v, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	if path == "-" {
		_, err = os.Stdout.Write(data)
		return err
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("create output directory %s: %w", dir, err)
		}
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, 0o644); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	if err := os.Rename(tmp, path); err != nil {
		os.Remove(tmp)
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

// ---------------------------------------------------------------------------
// snapshot command
// ---------------------------------------------------------------------------

type rootStat struct {
	root  string
	files int
	bytes int64
}

func cmdSnapshot(args []string) int {
	fset := flag.NewFlagSet("snapshot", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	fset.Usage = func() {}
	out := fset.String("out", "", "write the snapshot JSON to this file (\"-\" for stdout)")
	fset.StringVar(out, "o", "", "alias for --out")
	if err := fset.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageSnapshot(os.Stdout)
			return 0
		}
		return fail("%v", err)
	}
	roots := fset.Args()
	if len(roots) == 0 {
		return failUsage(usageSnapshot, "snapshot needs at least one directory to scan")
	}
	if *out == "" {
		return failUsage(usageSnapshot, "snapshot needs --out <snap.json>")
	}

	logw := os.Stdout
	if *out == "-" {
		logw = os.Stderr
	}

	var absRoots []string
	for _, r := range roots {
		abs, err := filepath.Abs(r)
		if err != nil {
			return fail("resolve %s: %v", r, err)
		}
		abs = filepath.Clean(abs)
		info, err := os.Stat(abs)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				return fail("root not found: %s", abs)
			}
			return fail("stat %s: %v", abs, err)
		}
		if !info.IsDir() {
			return fail("root is not a directory: %s", abs)
		}
		absRoots = append(absRoots, abs)
	}

	seen := make(map[string]bool)
	var files []FileEntry
	var stats []rootStat
	skippedNonRegular, walkErrors := 0, 0

	for _, root := range absRoots {
		st := rootStat{root: root}
		err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
			if err != nil {
				fmt.Fprintf(os.Stderr, "%s: warning: skipping %s: %v\n", appName, p, err)
				walkErrors++
				if d != nil && d.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
			if d.IsDir() {
				return nil
			}
			if !d.Type().IsRegular() {
				skippedNonRegular++
				return nil
			}
			info, err := d.Info()
			if err != nil {
				fmt.Fprintf(os.Stderr, "%s: warning: skipping %s: %v\n", appName, p, err)
				walkErrors++
				return nil
			}
			abs := filepath.Clean(p)
			if seen[abs] {
				return nil
			}
			seen[abs] = true
			rel, err := filepath.Rel(root, abs)
			if err != nil {
				rel = filepath.Base(abs)
			}
			files = append(files, FileEntry{
				Path:  abs,
				Root:  root,
				Rel:   filepath.ToSlash(rel),
				Size:  info.Size(),
				MTime: info.ModTime().UTC().Format(time.RFC3339Nano),
			})
			st.files++
			st.bytes += info.Size()
			return nil
		})
		if err != nil {
			return fail("walk %s: %v", root, err)
		}
		stats = append(stats, st)
	}

	sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path })
	if files == nil {
		files = []FileEntry{}
	}

	host, _ := os.Hostname()
	snap := &Snapshot{
		Schema:  schemaID,
		Tool:    appName,
		Version: appVersion,
		Created: time.Now().UTC().Format(time.RFC3339Nano),
		OS:      runtime.GOOS,
		Host:    host,
		Roots:   absRoots,
		Files:   files,
	}
	if err := writeJSON(*out, snap); err != nil {
		return fail("%v", err)
	}

	fmt.Fprintf(logw, "%s snapshot\n", appName)
	for _, s := range stats {
		fmt.Fprintf(logw, "  root  %s  (%s, %s)\n", s.root, plural(s.files, "file"), humanBytes(s.bytes))
	}
	if skippedNonRegular > 0 {
		fmt.Fprintf(logw, "  skipped %s (symlinks / non-regular)\n", plural(skippedNonRegular, "entry"))
	}
	if walkErrors > 0 {
		fmt.Fprintf(logw, "  %s during walk (see warnings above)\n", plural(walkErrors, "error"))
	}
	dest := *out
	if dest == "-" {
		dest = "<stdout>"
	}
	fmt.Fprintf(logw, "wrote %s  (%s, %s)\n", dest, plural(snap.totalFiles(), "file"), humanBytes(snap.TotalBytes()))
	return 0
}

func (s *Snapshot) totalFiles() int { return len(s.Files) }

// ---------------------------------------------------------------------------
// diff command
// ---------------------------------------------------------------------------

func cmdDiff(args []string) int {
	fset := flag.NewFlagSet("diff", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	fset.Usage = func() {}
	before := fset.String("before", "", "snapshot taken BEFORE the app was installed")
	after := fset.String("after", "", "snapshot taken AFTER the app was uninstalled")
	asJSON := fset.Bool("json", false, "emit the full report as JSON on stdout")
	if err := fset.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageDiff(os.Stdout)
			return 0
		}
		return fail("%v", err)
	}
	if extra := fset.Args(); len(extra) > 0 {
		return failUsage(usageDiff, "unexpected argument %q (diff takes only flags)", extra[0])
	}
	if *before == "" || *after == "" {
		return failUsage(usageDiff, "diff needs --before <a.json> and --after <b.json>")
	}

	b, err := loadSnapshot(*before)
	if err != nil {
		return fail("%v", err)
	}
	a, err := loadSnapshot(*after)
	if err != nil {
		return fail("%v", err)
	}

	d := computeDiff(b, a, *before, *after)
	if *asJSON {
		if err := writeJSON("-", d); err != nil {
			return fail("%v", err)
		}
		return 0
	}
	printDiff(os.Stdout, d)
	return 0
}

func printDiff(w io.Writer, d *DiffResult) {
	fmt.Fprintf(w, "%s diff\n", appName)
	fmt.Fprintf(w, "  before  %s  (%s, %s)\n", d.Before.File, plural(d.Before.Files, "file"), humanBytes(d.Before.Bytes))
	fmt.Fprintf(w, "  after   %s  (%s, %s)\n", d.After.File, plural(d.After.Files, "file"), humanBytes(d.After.Bytes))
	fmt.Fprintln(w)

	fmt.Fprintf(w, "ADDED     %s, %s   (leftovers: present after, absent before)\n",
		plural(d.Added.Count, "file"), humanBytes(d.Added.Bytes))
	for _, f := range d.Added.Files {
		fmt.Fprintf(w, "  + %s  (%s)\n", f.Path, humanBytes(f.Size))
	}
	fmt.Fprintf(w, "REMOVED   %s, %s\n", plural(d.Removed.Count, "file"), humanBytes(d.Removed.Bytes))
	for _, f := range d.Removed.Files {
		fmt.Fprintf(w, "  - %s  (%s)\n", f.Path, humanBytes(f.Size))
	}
	fmt.Fprintf(w, "MODIFIED  %s, %s\n", plural(d.Modified.Count, "file"), humanBytes(d.Modified.Bytes))
	for _, f := range d.Modified.Files {
		fmt.Fprintf(w, "  ~ %s  (%s)\n", f.Path, strings.Join(f.Reasons, "+"))
		fmt.Fprintf(w, "      size   %d -> %d\n", f.SizeBefore, f.SizeAfter)
		fmt.Fprintf(w, "      mtime  %s -> %s\n", f.MTimeBefore, f.MTimeAfter)
	}
	fmt.Fprintf(w, "UNCHANGED %s, %s\n", plural(d.Unchanged.Count, "file"), humanBytes(d.Unchanged.Bytes))
	fmt.Fprintln(w)
	if d.Clean() {
		fmt.Fprintf(w, "No differences: the two snapshots are identical. Nothing was left behind.\n")
		return
	}
	fmt.Fprintf(w, "%s left behind by the uninstaller, %s total.\n", plural(d.Added.Count, "file"), humanBytes(d.Added.Bytes))
	fmt.Fprintf(w, "Quarantine them with:  %s sweep --before %s --after %s --quarantine <dir> --apply\n",
		appName, d.Before.File, d.After.File)
}

// ---------------------------------------------------------------------------
// sweep command
// ---------------------------------------------------------------------------

func cmdSweep(args []string) int {
	fset := flag.NewFlagSet("sweep", flag.ContinueOnError)
	fset.SetOutput(io.Discard)
	fset.Usage = func() {}
	before := fset.String("before", "", "snapshot taken BEFORE the app was installed")
	after := fset.String("after", "", "snapshot taken AFTER the app was uninstalled")
	qdir := fset.String("quarantine", "", "directory to move leftovers into")
	fset.StringVar(qdir, "q", "", "alias for --quarantine")
	apply := fset.Bool("apply", false, "actually move the files (default is a dry run)")
	if err := fset.Parse(reorderFlags(args, valueFlags)); err != nil {
		if errors.Is(err, flag.ErrHelp) {
			usageSweep(os.Stdout)
			return 0
		}
		return fail("%v", err)
	}
	if extra := fset.Args(); len(extra) > 0 {
		return failUsage(usageSweep, "unexpected argument %q (sweep takes only flags)", extra[0])
	}
	if *before == "" || *after == "" {
		return failUsage(usageSweep, "sweep needs --before <a.json> and --after <b.json>")
	}
	if *qdir == "" {
		return failUsage(usageSweep, "sweep needs --quarantine <dir>")
	}

	b, err := loadSnapshot(*before)
	if err != nil {
		return fail("%v", err)
	}
	a, err := loadSnapshot(*after)
	if err != nil {
		return fail("%v", err)
	}
	qabs, err := filepath.Abs(*qdir)
	if err != nil {
		return fail("resolve quarantine dir %s: %v", *qdir, err)
	}
	qabs = filepath.Clean(qabs)

	d := computeDiff(b, a, *before, *after)
	w := os.Stdout

	mode := "DRY RUN"
	if *apply {
		mode = "APPLY"
	}
	fmt.Fprintf(w, "%s sweep  [%s]\n", appName, mode)
	fmt.Fprintf(w, "  before      %s  (%s)\n", *before, plural(d.Before.Files, "file"))
	fmt.Fprintf(w, "  after       %s  (%s)\n", *after, plural(d.After.Files, "file"))
	fmt.Fprintf(w, "  quarantine  %s\n", qabs)
	fmt.Fprintf(w, "  leftovers   %s, %s  (ADDED files only)\n", plural(d.Added.Count, "file"), humanBytes(d.Added.Bytes))
	fmt.Fprintf(w, "  protected   %s in BOTH snapshots — never touched\n",
		plural(d.Unchanged.Count+d.Modified.Count, "file"))
	fmt.Fprintln(w)

	if d.Added.Count == 0 {
		fmt.Fprintf(w, "Nothing to quarantine: no ADDED files between these snapshots.\n")
		return 0
	}

	var moved, skipped, failed, planned int
	var movedBytes, plannedBytes int64
	usedDest := make(map[string]bool)

	for _, f := range d.Added.Files {
		if isUnder(f.Path, qabs) {
			fmt.Fprintf(w, "  skip        %s  (already inside the quarantine directory)\n", f.Path)
			skipped++
			continue
		}
		if reason := skipReason(f); reason != "" {
			fmt.Fprintf(w, "  skip        %s  (%s)\n", f.Path, reason)
			skipped++
			continue
		}

		dest := uniqueDest(filepath.Join(qabs, mirrorRel(f.Path)), usedDest)
		usedDest[dest] = true

		if !*apply {
			fmt.Fprintf(w, "  would move  %s\n           -> %s  (%s)\n", f.Path, dest, humanBytes(f.Size))
			planned++
			plannedBytes += f.Size
			continue
		}

		if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
			fmt.Fprintf(w, "  FAIL        %s  (create %s: %v)\n", f.Path, filepath.Dir(dest), err)
			failed++
			continue
		}
		if err := moveFile(f.Path, dest); err != nil {
			fmt.Fprintf(w, "  FAIL        %s  (%v)\n", f.Path, err)
			failed++
			continue
		}
		fmt.Fprintf(w, "  quarantined %s\n           -> %s  (%s)\n", f.Path, dest, humanBytes(f.Size))
		moved++
		movedBytes += f.Size
	}

	fmt.Fprintln(w)
	if !*apply {
		fmt.Fprintf(w, "DRY RUN: nothing was moved. %s, %s would be quarantined.\n",
			plural(planned, "file"), humanBytes(plannedBytes))
		fmt.Fprintf(w, "Re-run the same command with --apply to actually quarantine them.\n")
		return 0
	}
	fmt.Fprintf(w, "Quarantined %s (%s) into %s.\n", plural(moved, "file"), humanBytes(movedBytes), qabs)
	if skipped > 0 {
		fmt.Fprintf(w, "Skipped %s (left exactly where they were).\n", plural(skipped, "file"))
	}
	if failed > 0 {
		fmt.Fprintf(w, "Failed %s.\n", plural(failed, "file"))
		return 1
	}
	if moved > 0 {
		fmt.Fprintf(w, "Nothing was deleted — every file above was moved, and can be moved back.\n")
	}
	return 0
}

// skipReason returns a human explanation if the leftover on disk no longer
// matches what the --after snapshot recorded, and "" if it is safe to move.
// It is applied identically in dry run and in --apply, so the dry run is a
// truthful preview.
func skipReason(f FileEntry) string {
	info, err := os.Lstat(f.Path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return "no longer exists"
		}
		return err.Error()
	}
	if !info.Mode().IsRegular() {
		return "not a regular file any more"
	}
	if info.Size() != f.Size || !mtimeEqual(info.ModTime().UTC().Format(time.RFC3339Nano), f.MTime) {
		return "changed since the --after snapshot; refusing to move"
	}
	return ""
}

// mirrorRel turns an absolute path into the relative path it occupies inside
// the quarantine directory. The whole directory structure is mirrored, so the
// path of a leftover relative to its snapshot root is preserved exactly and
// leftovers from different roots can never collide.
func mirrorRel(p string) string {
	vol := filepath.VolumeName(p)
	rest := p[len(vol):]
	vol = strings.NewReplacer(":", "", `\`, "", "/", "").Replace(vol)
	rest = strings.TrimLeft(rest, `/\`)
	if vol != "" {
		return filepath.Join(vol, rest)
	}
	return rest
}

// uniqueDest never overwrites: an occupied destination gains a numeric suffix.
func uniqueDest(dest string, used map[string]bool) string {
	candidate := dest
	for i := 1; ; i++ {
		_, err := os.Lstat(candidate)
		if err != nil && errors.Is(err, os.ErrNotExist) && !used[candidate] {
			return candidate
		}
		candidate = fmt.Sprintf("%s.%d", dest, i)
		if i > 10000 {
			return candidate
		}
	}
}

// isUnder reports whether p is dir itself or lives inside dir.
func isUnder(p, dir string) bool {
	rel, err := filepath.Rel(dir, p)
	if err != nil {
		return false
	}
	if rel == "." {
		return true
	}
	return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}

// moveFile moves src to dst, falling back to copy+remove across filesystems.
// It never removes the source until the copy is complete and flushed.
func moveFile(src, dst string) error {
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	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.Sync(); 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())
	if err := os.Remove(src); err != nil {
		return fmt.Errorf("copied to quarantine but could not remove original: %w", err)
	}
	return nil
}

// ---------------------------------------------------------------------------
// usage / errors / main
// ---------------------------------------------------------------------------

var valueFlags = map[string]bool{
	"out": true, "o": true,
	"before": true, "after": true,
	"quarantine": true, "q": true,
}

func plural(n int, word string) string {
	if n == 1 {
		return fmt.Sprintf("1 %s", word)
	}
	switch {
	case strings.HasSuffix(word, "y"):
		return fmt.Sprintf("%d %sies", n, strings.TrimSuffix(word, "y"))
	default:
		return fmt.Sprintf("%d %ss", n, word)
	}
}

func fail(format string, a ...any) int {
	fmt.Fprintf(os.Stderr, "%s: error: %s\n", appName, fmt.Sprintf(format, a...))
	return 1
}

func failUsage(u func(io.Writer), format string, a ...any) int {
	fmt.Fprintf(os.Stderr, "%s: error: %s\n\n", appName, fmt.Sprintf(format, a...))
	u(os.Stderr)
	return 1
}

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s %s — find what an uninstaller left behind, by snapshot diff.

A pattern-based cleaner can only find leftovers somebody wrote a pattern for.
%s compares a filesystem snapshot taken BEFORE you installed an app with one
taken AFTER you uninstalled it, so it finds every orphan — including the ones
nobody anticipated.

USAGE
  %s <command> [flags]

COMMANDS
  snapshot   record path, size and mtime of every file under one or more roots
  diff       report ADDED / REMOVED / MODIFIED files between two snapshots
  sweep      quarantine the ADDED files (the leftovers). Dry run by default.
  help       show this help, or "help <command>"
  version    print the version

TYPICAL SESSION
  %s snapshot ~/AppData /Program\ Files --out before.json
  ... install the app, use it, then uninstall it ...
  %s snapshot ~/AppData /Program\ Files --out after.json
  %s diff  --before before.json --after after.json
  %s sweep --before before.json --after after.json --quarantine ./quarantine
  %s sweep --before before.json --after after.json --quarantine ./quarantine --apply

SAFETY
  sweep is a DRY RUN unless you pass --apply.
  sweep only ever touches ADDED files. Anything present in BOTH snapshots is
  left alone. Nothing is ever deleted — files are MOVED into the quarantine
  directory with their directory structure preserved, so you can move them back.

EXIT STATUS
  0  success (including "differences found")
  1  bad invocation, unreadable/corrupt snapshot, or a failed move

Flags may appear before or after positional arguments.
`, appName, appVersion, appName, appName, appName, appName, appName, appName, appName)
}

func usageSnapshot(w io.Writer) {
	fmt.Fprintf(w, `%s snapshot — record every file under one or more roots.

USAGE
  %s snapshot <dir> [<dir> ...] --out <snap.json>

FLAGS
  --out, -o <file>   where to write the snapshot JSON ("-" writes to stdout)
  -h, --help         show this help

NOTES
  Records path, size and mtime for every regular file found under each root.
  Directories, symlinks, sockets and devices are not recorded; unreadable
  directories are reported as warnings and skipped.
  Roots are resolved to absolute paths and the file list is sorted, so two
  snapshots of an unchanged tree are byte-identical apart from "created".

EXAMPLES
  %s snapshot /opt/app --out before.json
  %s snapshot /opt /var/lib --out before.json
  %s snapshot --out before.json /opt /var/lib
`, appName, appName, appName, appName, appName)
}

func usageDiff(w io.Writer) {
	fmt.Fprintf(w, `%s diff — report what changed between two snapshots.

USAGE
  %s diff --before <a.json> --after <b.json> [--json]

FLAGS
  --before <file>   snapshot taken BEFORE the app was installed
  --after  <file>   snapshot taken AFTER the app was uninstalled
  --json            emit the full machine-readable report on stdout
  -h, --help        show this help

CATEGORIES
  ADDED     in --after but not in --before  — these are the leftovers
  REMOVED   in --before but not in --after
  MODIFIED  in both, but the size OR the mtime differs
  UNCHANGED in both, same size and same mtime

  Byte totals: ADDED and MODIFIED are summed from the --after sizes,
  REMOVED from the --before sizes.

EXAMPLES
  %s diff --before before.json --after after.json
  %s diff --before before.json --after after.json --json | jq .added
`, appName, appName, appName, appName)
}

func usageSweep(w io.Writer) {
	fmt.Fprintf(w, `%s sweep — quarantine the leftovers found by a diff.

USAGE
  %s sweep --before <a.json> --after <b.json> --quarantine <qdir> [--apply]

FLAGS
  --before <file>          snapshot taken BEFORE the app was installed
  --after  <file>          snapshot taken AFTER the app was uninstalled
  --quarantine, -q <dir>   directory the leftovers are moved into
  --apply                  actually move the files (default: dry run)
  -h, --help               show this help

SAFETY
  Without --apply nothing is touched; the planned moves are printed instead.
  Only ADDED files are ever moved. Files present in BOTH snapshots — including
  MODIFIED ones — are never touched.
  Nothing is deleted. Each leftover is MOVED under <qdir>, mirroring its full
  directory structure (/opt/app/x.so -> <qdir>/opt/app/x.so), so relative paths
  are preserved and roots cannot collide. An existing destination is never
  overwritten; a numeric suffix is added instead.
  A leftover whose size or mtime no longer matches the --after snapshot is
  skipped rather than moved, because it changed after the snapshot was taken.

EXAMPLES
  %s sweep --before before.json --after after.json --quarantine ./q
  %s sweep --before before.json --after after.json --quarantine ./q --apply
`, appName, appName, appName, appName)
}

func helpFor(name string) (func(io.Writer), bool) {
	switch name {
	case "snapshot":
		return usageSnapshot, true
	case "diff":
		return usageDiff, true
	case "sweep":
		return usageSweep, true
	}
	return nil, false
}

func run(args []string) int {
	if len(args) == 0 {
		fmt.Fprintf(os.Stderr, "%s: error: no command given\n\n", appName)
		usage(os.Stderr)
		return 1
	}
	switch args[0] {
	case "-h", "--help", "-help", "help":
		if len(args) > 1 {
			if u, ok := helpFor(args[1]); ok {
				u(os.Stdout)
				return 0
			}
			return failUsage(usage, "unknown command %q", args[1])
		}
		usage(os.Stdout)
		return 0
	case "version", "--version", "-version", "-v", "--v":
		fmt.Printf("%s %s (%s/%s, %s)\n", appName, appVersion, runtime.GOOS, runtime.GOARCH, runtime.Version())
		return 0
	case "snapshot":
		return cmdSnapshot(args[1:])
	case "diff":
		return cmdDiff(args[1:])
	case "sweep":
		return cmdSweep(args[1:])
	}
	return failUsage(usage, "unknown command %q", args[0])
}

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
		}
	}
	os.Exit(run(os.Args[1:]))
}
