// Command cleanvault scans a home directory for browser and application
// privacy traces using a built-in catalog, and quarantines what it finds.
//
// CleanVault never hard-deletes. Anything it acts on is MOVED into a
// quarantine directory with its relative path preserved, and only when the
// operator passes --apply. Without --apply every destructive command is a
// dry run.
package main

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

const (
	toolName = "cleanvault"
	version  = "1.0.0"
)

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

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

// ---------------------------------------------------------------------------
// Catalog
// ---------------------------------------------------------------------------

// Scope describes which root an entry is resolved against.
const (
	scopeHome   = "home"   // relative to --home
	scopeSystem = "system" // relative to --system (machine-wide, opt-in)
)

// Entry is one catalog record: where a privacy artifact lives for one OS.
type Entry struct {
	OS          string `json:"os"`
	App         string `json:"app"`
	Artifact    string `json:"artifact"`
	Scope       string `json:"scope"`
	Path        string `json:"path"`
	Sensitivity string `json:"sensitivity"`
	Note        string `json:"note,omitempty"`
}

// ID is a stable identifier for an entry, used in output and manifests.
func (e Entry) ID() string {
	return fmt.Sprintf("%s/%s/%s", e.OS, slug(e.App), slug(e.Artifact))
}

func slug(s string) string {
	var b strings.Builder
	prevDash := false
	for _, r := range strings.ToLower(s) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			b.WriteRune(r)
			prevDash = false
		default:
			if !prevDash && b.Len() > 0 {
				b.WriteByte('-')
				prevDash = true
			}
		}
	}
	return strings.Trim(b.String(), "-")
}

const (
	sensLow    = "low"
	sensMedium = "medium"
	sensHigh   = "high"
)

func sensRank(s string) int {
	switch s {
	case sensLow:
		return 1
	case sensMedium:
		return 2
	case sensHigh:
		return 3
	}
	return 0
}

// chromiumFamily expands the five standard Chromium-profile artifacts for a
// browser whose profile directory lives at profileBase and whose on-disk cache
// lives at cacheBase. Both are slash-separated, root-relative globs where the
// final "*" selects the profile ("Default", "Profile 1", ...).
func chromiumFamily(goos, app, profileBase, cacheBase string) []Entry {
	e := []Entry{
		{OS: goos, App: app, Artifact: "Cache", Scope: scopeHome, Path: cacheBase + "/Cache", Sensitivity: sensMedium,
			Note: "rendered pages, images and scripts; reveals browsing history indirectly"},
		{OS: goos, App: app, Artifact: "Code Cache", Scope: scopeHome, Path: cacheBase + "/Code Cache", Sensitivity: sensLow,
			Note: "compiled JavaScript for visited sites"},
		{OS: goos, App: app, Artifact: "Cookies", Scope: scopeHome, Path: profileBase + "/Cookies", Sensitivity: sensHigh,
			Note: "SQLite cookie jar; contains live session tokens"},
		{OS: goos, App: app, Artifact: "Cookies (Network)", Scope: scopeHome, Path: profileBase + "/Network/Cookies", Sensitivity: sensHigh,
			Note: "cookie jar location used by Chrome 96+"},
		{OS: goos, App: app, Artifact: "History", Scope: scopeHome, Path: profileBase + "/History*", Sensitivity: sensHigh,
			Note: "SQLite URL/visit/download history plus its journals"},
		{OS: goos, App: app, Artifact: "Sessions", Scope: scopeHome, Path: profileBase + "/Sessions", Sensitivity: sensHigh,
			Note: "open tabs and restorable session state"},
		{OS: goos, App: app, Artifact: "Login Data", Scope: scopeHome, Path: profileBase + "/Login Data*", Sensitivity: sensHigh,
			Note: "saved credential database"},
		{OS: goos, App: app, Artifact: "Top Sites", Scope: scopeHome, Path: profileBase + "/Top Sites*", Sensitivity: sensMedium,
			Note: "most-visited sites shown on the new tab page"},
	}
	return e
}

// firefoxFamily expands the standard Firefox profile artifacts. profileBase is
// the glob selecting a profile directory; cacheBase selects the matching cache
// profile directory.
func firefoxFamily(goos, profileBase, cacheBase string) []Entry {
	return []Entry{
		{OS: goos, App: "Firefox", Artifact: "cache2", Scope: scopeHome, Path: cacheBase + "/cache2", Sensitivity: sensMedium,
			Note: "HTTP cache v2 storage"},
		{OS: goos, App: "Firefox", Artifact: "cookies.sqlite", Scope: scopeHome, Path: profileBase + "/cookies.sqlite*", Sensitivity: sensHigh,
			Note: "cookie jar; contains live session tokens"},
		{OS: goos, App: "Firefox", Artifact: "places.sqlite", Scope: scopeHome, Path: profileBase + "/places.sqlite*", Sensitivity: sensHigh,
			Note: "history and bookmarks database"},
		{OS: goos, App: "Firefox", Artifact: "formhistory.sqlite", Scope: scopeHome, Path: profileBase + "/formhistory.sqlite*", Sensitivity: sensHigh,
			Note: "saved form field values"},
		{OS: goos, App: "Firefox", Artifact: "sessionstore", Scope: scopeHome, Path: profileBase + "/sessionstore*", Sensitivity: sensHigh,
			Note: "sessionstore.jsonlz4 and sessionstore-backups: open and recently closed tabs"},
		{OS: goos, App: "Firefox", Artifact: "logins.json", Scope: scopeHome, Path: profileBase + "/logins.json", Sensitivity: sensHigh,
			Note: "saved credential store"},
		{OS: goos, App: "Firefox", Artifact: "thumbnails", Scope: scopeHome, Path: cacheBase + "/thumbnails", Sensitivity: sensMedium,
			Note: "new-tab-page screenshots of visited sites"},
	}
}

// catalog returns the built-in catalog for one OS, in resolution order.
// Order matters: the first entry that claims a file owns it, so specific
// entries are listed before broad sweep entries such as ~/.cache.
func catalog(goos string) []Entry {
	switch goos {
	case "linux":
		return linuxCatalog()
	case "darwin":
		return darwinCatalog()
	case "windows":
		return windowsCatalog()
	}
	return nil
}

func linuxCatalog() []Entry {
	var e []Entry
	e = append(e, chromiumFamily("linux", "Google Chrome", ".config/google-chrome/*", ".cache/google-chrome/*")...)
	e = append(e, []Entry{
		{OS: "linux", App: "Google Chrome", Artifact: "Cache (in-profile)", Scope: scopeHome, Path: ".config/google-chrome/*/Cache", Sensitivity: sensMedium,
			Note: "cache location used when Chrome is run with a custom --user-data-dir or by some distro builds"},
		{OS: "linux", App: "Google Chrome", Artifact: "Code Cache (in-profile)", Scope: scopeHome, Path: ".config/google-chrome/*/Code Cache", Sensitivity: sensLow,
			Note: "in-profile compiled JavaScript cache"},
	}...)
	e = append(e, chromiumFamily("linux", "Chromium", ".config/chromium/*", ".cache/chromium/*")...)
	e = append(e, []Entry{
		{OS: "linux", App: "Chromium", Artifact: "Cache (in-profile)", Scope: scopeHome, Path: ".config/chromium/*/Cache", Sensitivity: sensMedium,
			Note: "in-profile cache location used by some distro builds"},
	}...)
	e = append(e, chromiumFamily("linux", "Microsoft Edge", ".config/microsoft-edge/*", ".cache/microsoft-edge/*")...)
	e = append(e, firefoxFamily("linux", ".mozilla/firefox/*", ".cache/mozilla/firefox/*")...)
	e = append(e, []Entry{
		{OS: "linux", App: "Google Chrome", Artifact: "Crash Reports", Scope: scopeHome, Path: ".config/google-chrome/Crash Reports", Sensitivity: sensLow,
			Note: "crashpad dumps; may embed page URLs and memory"},
		{OS: "linux", App: "Freedesktop", Artifact: "recently-used.xbel", Scope: scopeHome, Path: ".local/share/recently-used.xbel*", Sensitivity: sensMedium,
			Note: "recent documents list shared by GTK applications"},
		{OS: "linux", App: "Freedesktop", Artifact: "Thumbnail cache", Scope: scopeHome, Path: ".cache/thumbnails", Sensitivity: sensMedium,
			Note: "generated previews of every file browsed in a file manager"},
		{OS: "linux", App: "Freedesktop", Artifact: "Trash", Scope: scopeHome, Path: ".local/share/Trash", Sensitivity: sensMedium,
			Note: "deleted files still recoverable from the desktop trash"},
		{OS: "linux", App: "Shell", Artifact: "Shell history", Scope: scopeHome, Path: ".bash_history", Sensitivity: sensMedium,
			Note: "typed commands, sometimes including secrets"},
		{OS: "linux", App: "systemd", Artifact: "Core dumps", Scope: scopeHome, Path: ".cache/coredumpctl", Sensitivity: sensLow,
			Note: "user-scope crash dumps"},
		{OS: "linux", App: "XDG", Artifact: "User cache (sweep)", Scope: scopeHome, Path: ".cache", Sensitivity: sensLow,
			Note: "catch-all sweep of ~/.cache for anything not claimed above"},
	}...)
	return e
}

func darwinCatalog() []Entry {
	var e []Entry
	e = append(e, []Entry{
		{OS: "darwin", App: "Safari", Artifact: "History", Scope: scopeHome, Path: "Library/Safari/History.db*", Sensitivity: sensHigh,
			Note: "URL and visit history database plus WAL journals"},
		{OS: "darwin", App: "Safari", Artifact: "Downloads.plist", Scope: scopeHome, Path: "Library/Safari/Downloads.plist", Sensitivity: sensHigh,
			Note: "download history"},
		{OS: "darwin", App: "Safari", Artifact: "Sessions", Scope: scopeHome, Path: "Library/Safari/LastSession.plist", Sensitivity: sensHigh,
			Note: "open tabs restored at launch"},
		{OS: "darwin", App: "Safari", Artifact: "TopSites.plist", Scope: scopeHome, Path: "Library/Safari/TopSites.plist", Sensitivity: sensMedium,
			Note: "most-visited sites"},
		{OS: "darwin", App: "Safari", Artifact: "Cookies", Scope: scopeHome, Path: "Library/Cookies/*.binarycookies", Sensitivity: sensHigh,
			Note: "binary cookie jar; contains live session tokens"},
		{OS: "darwin", App: "Safari", Artifact: "Container cookies", Scope: scopeHome, Path: "Library/Containers/com.apple.Safari/Data/Library/Cookies", Sensitivity: sensHigh,
			Note: "sandboxed Safari cookie storage"},
		{OS: "darwin", App: "Safari", Artifact: "Caches", Scope: scopeHome, Path: "Library/Containers/com.apple.Safari/Data/Library/Caches", Sensitivity: sensMedium,
			Note: "sandboxed Safari cache"},
		{OS: "darwin", App: "Safari", Artifact: "Legacy cache", Scope: scopeHome, Path: "Library/Caches/com.apple.Safari", Sensitivity: sensMedium,
			Note: "pre-sandbox Safari cache location"},
	}...)
	e = append(e, chromiumFamily("darwin", "Google Chrome", "Library/Application Support/Google/Chrome/*", "Library/Caches/Google/Chrome/*")...)
	e = append(e, chromiumFamily("darwin", "Chromium", "Library/Application Support/Chromium/*", "Library/Caches/Chromium/*")...)
	e = append(e, chromiumFamily("darwin", "Microsoft Edge", "Library/Application Support/Microsoft Edge/*", "Library/Caches/Microsoft Edge/*")...)
	e = append(e, firefoxFamily("darwin", "Library/Application Support/Firefox/Profiles/*", "Library/Caches/Firefox/Profiles/*")...)
	e = append(e, []Entry{
		{OS: "darwin", App: "macOS", Artifact: "Recent documents", Scope: scopeHome, Path: "Library/Application Support/com.apple.sharedfilelist", Sensitivity: sensMedium,
			Note: "recent files, servers and applications shown in menus"},
		{OS: "darwin", App: "macOS", Artifact: "Quick Look thumbnails", Scope: scopeHome, Path: "Library/Caches/com.apple.QuickLook.thumbnailcache", Sensitivity: sensMedium,
			Note: "generated previews of files opened in Finder"},
		{OS: "darwin", App: "macOS", Artifact: "Crash reports", Scope: scopeHome, Path: "Library/Logs/DiagnosticReports", Sensitivity: sensLow,
			Note: "per-user crash and spin reports"},
		{OS: "darwin", App: "macOS", Artifact: "Saved application state", Scope: scopeHome, Path: "Library/Saved Application State", Sensitivity: sensMedium,
			Note: "window contents restored when applications relaunch"},
		{OS: "darwin", App: "macOS", Artifact: "User caches (sweep)", Scope: scopeHome, Path: "Library/Caches", Sensitivity: sensLow,
			Note: "catch-all sweep of ~/Library/Caches for anything not claimed above"},
		{OS: "darwin", App: "macOS", Artifact: "User logs (sweep)", Scope: scopeHome, Path: "Library/Logs", Sensitivity: sensLow,
			Note: "catch-all sweep of ~/Library/Logs for anything not claimed above"},
	}...)
	return e
}

func windowsCatalog() []Entry {
	var e []Entry
	e = append(e, chromiumFamily("windows", "Google Chrome",
		"AppData/Local/Google/Chrome/User Data/*", "AppData/Local/Google/Chrome/User Data/*")...)
	e = append(e, chromiumFamily("windows", "Chromium",
		"AppData/Local/Chromium/User Data/*", "AppData/Local/Chromium/User Data/*")...)
	e = append(e, chromiumFamily("windows", "Microsoft Edge",
		"AppData/Local/Microsoft/Edge/User Data/*", "AppData/Local/Microsoft/Edge/User Data/*")...)
	e = append(e, firefoxFamily("windows",
		"AppData/Roaming/Mozilla/Firefox/Profiles/*", "AppData/Local/Mozilla/Firefox/Profiles/*")...)
	e = append(e, []Entry{
		{OS: "windows", App: "Internet Explorer", Artifact: "WebCache", Scope: scopeHome, Path: "AppData/Local/Microsoft/Windows/WebCache", Sensitivity: sensHigh,
			Note: "ESE database holding IE/legacy Edge history and cookies"},
		{OS: "windows", App: "Internet Explorer", Artifact: "INetCache", Scope: scopeHome, Path: "AppData/Local/Microsoft/Windows/INetCache", Sensitivity: sensMedium,
			Note: "temporary internet files"},
		{OS: "windows", App: "Windows", Artifact: "Recent", Scope: scopeHome, Path: "AppData/Roaming/Microsoft/Windows/Recent", Sensitivity: sensMedium,
			Note: "recent documents shortcuts, jump lists and automatic destinations"},
		{OS: "windows", App: "Windows", Artifact: "Thumbnail cache", Scope: scopeHome, Path: "AppData/Local/Microsoft/Windows/Explorer/thumbcache_*.db", Sensitivity: sensMedium,
			Note: "Explorer thumbnail databases; previews survive file deletion"},
		{OS: "windows", App: "Windows", Artifact: "Icon cache", Scope: scopeHome, Path: "AppData/Local/Microsoft/Windows/Explorer/iconcache_*.db", Sensitivity: sensLow,
			Note: "Explorer icon databases"},
		{OS: "windows", App: "Windows", Artifact: "Temp", Scope: scopeHome, Path: "AppData/Local/Temp", Sensitivity: sensLow,
			Note: "per-user temporary files; often holds opened attachments"},
		{OS: "windows", App: "Windows", Artifact: "CrashDumps", Scope: scopeHome, Path: "AppData/Local/CrashDumps", Sensitivity: sensLow,
			Note: "application crash dumps; may contain memory contents"},
		{OS: "windows", App: "Windows", Artifact: "Error Reporting", Scope: scopeHome, Path: "AppData/Local/Microsoft/Windows/WER", Sensitivity: sensLow,
			Note: "Windows Error Reporting queue and archive"},
		{OS: "windows", App: "Windows", Artifact: "PowerShell history", Scope: scopeHome, Path: "AppData/Roaming/Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt", Sensitivity: sensMedium,
			Note: "typed commands, sometimes including secrets"},
		{OS: "windows", App: "Windows", Artifact: "Prefetch", Scope: scopeSystem, Path: "Windows/Prefetch", Sensitivity: sensLow,
			Note: "machine-wide record of every executable run; needs --system (usually C:\\)"},
		{OS: "windows", App: "Windows", Artifact: "System Temp", Scope: scopeSystem, Path: "Windows/Temp", Sensitivity: sensLow,
			Note: "machine-wide temporary files; needs --system (usually C:\\)"},
	}...)
	return e
}

func supportedOS() []string { return []string{"darwin", "linux", "windows"} }

func normalizeOS(s string) (string, error) {
	s = strings.ToLower(strings.TrimSpace(s))
	switch s {
	case "windows", "win":
		return "windows", nil
	case "darwin", "macos", "mac", "osx":
		return "darwin", nil
	case "linux":
		return "linux", nil
	}
	return "", fmt.Errorf("unsupported --os %q (want one of: %s)", s, strings.Join(supportedOS(), ", "))
}

func defaultOS() string {
	if o, err := normalizeOS(runtime.GOOS); err == nil {
		return o
	}
	return "linux"
}

// ---------------------------------------------------------------------------
// Resolution
// ---------------------------------------------------------------------------

type fileRef struct {
	Rel  string `json:"rel"`
	Abs  string `json:"abs"`
	Size int64  `json:"size"`
}

// Result is the resolution of one catalog entry against the roots.
type Result struct {
	Entry   Entry     `json:"entry"`
	ID      string    `json:"id"`
	Status  string    `json:"status"` // found | absent | skipped
	Reason  string    `json:"reason,omitempty"`
	Root    string    `json:"root,omitempty"`
	Files   int       `json:"files"`
	Bytes   int64     `json:"bytes"`
	Human   string    `json:"bytes_human"`
	Matches []fileRef `json:"matches,omitempty"`
}

const (
	statusFound   = "found"
	statusAbsent  = "absent"
	statusSkipped = "skipped"
)

type roots struct {
	home   string
	system string // may be empty: machine-scope entries are then skipped
}

func (r roots) rootFor(scope string) string {
	if scope == scopeSystem {
		return r.system
	}
	return r.home
}

// resolve walks the catalog for goos and reports what exists under the roots.
// A file is claimed by the first entry that matches it, so per-entry counts sum
// exactly to the totals without double counting overlapping globs.
func resolve(entries []Entry, r roots, minSens string) ([]Result, error) {
	seen := map[string]bool{}
	out := make([]Result, 0, len(entries))
	for _, e := range entries {
		res := Result{Entry: e, ID: e.ID(), Status: statusAbsent, Human: humanBytes(0)}
		if sensRank(e.Sensitivity) < sensRank(minSens) {
			res.Status = statusSkipped
			res.Reason = "below --min-sensitivity " + minSens
			out = append(out, res)
			continue
		}
		root := r.rootFor(e.Scope)
		if root == "" {
			res.Status = statusSkipped
			res.Reason = "machine-scope entry; pass --system <root> to include it"
			out = append(out, res)
			continue
		}
		res.Root = root
		files, err := collect(root, e.Path, seen)
		if err != nil {
			return nil, err
		}
		for _, f := range files {
			res.Files++
			res.Bytes += f.Size
		}
		res.Matches = files
		res.Human = humanBytes(res.Bytes)
		if res.Files > 0 {
			res.Status = statusFound
		} else {
			res.Reason = "no files matched under " + root
		}
		out = append(out, res)
	}
	return out, nil
}

// collect expands one catalog glob under root and returns the regular files it
// covers. Symlinks are never followed and never claimed, and every result is
// re-checked to be inside root before it is returned.
func collect(root, pattern string, seen map[string]bool) ([]fileRef, error) {
	if strings.Contains(pattern, "..") {
		return nil, fmt.Errorf("refusing catalog pattern with %q: %s", "..", pattern)
	}
	full := filepath.Join(root, filepath.FromSlash(pattern))
	matches, err := filepath.Glob(full)
	if err != nil {
		return nil, fmt.Errorf("bad catalog pattern %q: %w", pattern, err)
	}
	sort.Strings(matches)
	var out []fileRef
	add := func(p string, size int64) {
		abs, err := filepath.Abs(p)
		if err != nil {
			return
		}
		rel, err := filepath.Rel(root, abs)
		if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
			return
		}
		if seen[abs] {
			return
		}
		seen[abs] = true
		out = append(out, fileRef{Rel: filepath.ToSlash(rel), Abs: abs, Size: size})
	}
	for _, m := range matches {
		fi, err := os.Lstat(m)
		if err != nil {
			continue
		}
		switch {
		case fi.Mode()&os.ModeSymlink != 0:
			// Never traverse or claim a symlink: it can point anywhere.
			continue
		case fi.IsDir():
			err = filepath.WalkDir(m, func(p string, d fs.DirEntry, err error) error {
				if err != nil {
					return nil
				}
				if d.Type()&os.ModeSymlink != 0 {
					if d.IsDir() {
						return fs.SkipDir
					}
					return nil
				}
				if d.IsDir() {
					return nil
				}
				info, err := d.Info()
				if err != nil || !info.Mode().IsRegular() {
					return nil
				}
				add(p, info.Size())
				return nil
			})
			if err != nil {
				return nil, err
			}
		case fi.Mode().IsRegular():
			add(m, fi.Size())
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Abs < out[j].Abs })
	return out, nil
}

func totals(results []Result) (entriesFound, files int, bytes int64) {
	for _, r := range results {
		if r.Status != statusFound {
			continue
		}
		entriesFound++
		files += r.Files
		bytes += r.Bytes
	}
	return
}

// ---------------------------------------------------------------------------
// Usage
// ---------------------------------------------------------------------------

func usage(w io.Writer) {
	fmt.Fprintf(w, `%s %s - find and quarantine browser/app privacy traces.

USAGE
  cleanvault <command> [flags]

COMMANDS
  catalog   Print the built-in artifact catalog.
  scan      Resolve the catalog against a home root and report what exists.
  clean     Quarantine matched files (DRY RUN unless --apply).
  help      Print this help.

CATALOG
  cleanvault catalog [--os windows|darwin|linux] [--json]

SCAN
  cleanvault scan [<home-root>] [--home <root>] [--os ...] [--system <root>]
                  [--min-sensitivity low|medium|high] [--json]

CLEAN
  cleanvault clean [<home-root>] [--home <root>] --quarantine <qdir> [--os ...]
                   [--system <root>] [--min-sensitivity low|medium|high]
                   [--json] [--apply]

COMMON FLAGS
  --os <name>               Catalog to use. Default: this machine (%s).
  --home <root>             Root that home-scope catalog paths resolve against.
                            Default: the current user's home directory.
  --system <root>           Root for machine-scope entries (e.g. Windows
                            Prefetch). Omitted by default, so those entries are
                            skipped.
  --min-sensitivity <lvl>   Ignore entries below this level. Default: low.
  --json                    Machine-readable output.
  --apply                   clean only. Without it nothing is ever moved.
  -h, --help                This help (exit 0).

SAFETY
  cleanvault never deletes. clean --apply MOVES files into the quarantine
  directory, preserving each file's path relative to its root under
  <qdir>/home/... or <qdir>/system/..., and writes a manifest describing every
  move. Restore by moving files back. Only paths produced by the built-in
  catalog are ever touched; everything else in --home is left alone.

EXIT STATUS
  0 success, 1 usage error, operational error, or partial clean failure.
`, toolName, version, defaultOS())
}

func usageErr(format string, args ...any) error {
	return usageError{fmt.Errorf(format, args...)}
}

type usageError struct{ err error }

func (u usageError) Error() string { return u.err.Error() }

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

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.Stderr)
		os.Exit(1)
	}
	switch args[0] {
	case "-h", "--help", "help":
		usage(os.Stdout)
		os.Exit(0)
	}
	cmd, rest := args[0], args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" {
			usage(os.Stdout)
			os.Exit(0)
		}
	}

	var err error
	switch cmd {
	case "catalog":
		err = cmdCatalog(rest)
	case "scan":
		err = cmdScan(rest)
	case "clean":
		err = cmdClean(rest)
	case "version", "--version":
		fmt.Printf("%s %s\n", toolName, version)
	default:
		fmt.Fprintf(os.Stderr, "%s: unknown command %q\n\n", toolName, cmd)
		usage(os.Stderr)
		os.Exit(1)
	}
	if err != nil {
		fmt.Fprintf(os.Stderr, "%s: %v\n", toolName, err)
		var ue usageError
		if errors.As(err, &ue) {
			fmt.Fprintln(os.Stderr)
			usage(os.Stderr)
		}
		os.Exit(1)
	}
}

var valueFlags = map[string]bool{
	"os": true, "home": true, "system": true,
	"quarantine": true, "min-sensitivity": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	return fs
}

func parseMinSens(s string) (string, error) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case sensLow:
		return sensLow, nil
	case sensMedium:
		return sensMedium, nil
	case sensHigh:
		return sensHigh, nil
	}
	return "", usageErr("invalid --min-sensitivity %q (want low, medium or high)", s)
}

func writeJSON(v any) error {
	enc := json.NewEncoder(os.Stdout)
	enc.SetIndent("", "  ")
	return enc.Encode(v)
}

// ---------------------------------------------------------------------------
// catalog command
// ---------------------------------------------------------------------------

func cmdCatalog(args []string) error {
	fs := newFlagSet("catalog")
	osName := fs.String("os", defaultOS(), "catalog to print")
	asJSON := fs.Bool("json", false, "JSON output")
	minSens := fs.String("min-sensitivity", sensLow, "minimum sensitivity")
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		return usageErr("%v", err)
	}
	if fs.NArg() > 0 {
		return usageErr("unexpected argument %q", fs.Arg(0))
	}
	goos, err := normalizeOS(*osName)
	if err != nil {
		return usageError{err}
	}
	ms, err := parseMinSens(*minSens)
	if err != nil {
		return err
	}
	entries := catalog(goos)
	kept := entries[:0:0]
	for _, e := range entries {
		if sensRank(e.Sensitivity) >= sensRank(ms) {
			kept = append(kept, e)
		}
	}
	if *asJSON {
		return writeJSON(struct {
			Tool    string  `json:"tool"`
			Version string  `json:"version"`
			OS      string  `json:"os"`
			Count   int     `json:"count"`
			Entries []Entry `json:"entries"`
		}{toolName, version, goos, len(kept), kept})
	}

	fmt.Printf("CleanVault catalog for %s (%d entries)\n\n", goos, len(kept))
	fmt.Printf("%-8s  %-6s  %-28s  %s\n", "SENS", "SCOPE", "APP / ARTIFACT", "PATH (relative to root)")
	fmt.Println(strings.Repeat("-", 110))
	for _, e := range kept {
		fmt.Printf("%-8s  %-6s  %-28s  %s\n", e.Sensitivity, e.Scope,
			trunc(e.App+" / "+e.Artifact, 28), e.Path)
	}
	fmt.Println()
	fmt.Println("home-scope paths resolve under --home; system-scope paths under --system.")
	return nil
}

func trunc(s string, n int) string {
	if len(s) <= n {
		return s
	}
	if n <= 3 {
		return s[:n]
	}
	return s[:n-3] + "..."
}

// ---------------------------------------------------------------------------
// scan command
// ---------------------------------------------------------------------------

type scanOpts struct {
	goos    string
	roots   roots
	minSens string
	asJSON  bool
}

func bindCommon(fs *flag.FlagSet) (osName, home, system, minSens *string, asJSON *bool) {
	osName = fs.String("os", defaultOS(), "catalog to use")
	home = fs.String("home", "", "home root")
	system = fs.String("system", "", "machine-wide root")
	minSens = fs.String("min-sensitivity", sensLow, "minimum sensitivity")
	asJSON = fs.Bool("json", false, "JSON output")
	return
}

func buildOpts(osName, home, system, minSens string, asJSON bool, requireHomeExists bool) (scanOpts, error) {
	var o scanOpts
	goos, err := normalizeOS(osName)
	if err != nil {
		return o, usageError{err}
	}
	ms, err := parseMinSens(minSens)
	if err != nil {
		return o, err
	}
	if home == "" {
		h, err := os.UserHomeDir()
		if err != nil {
			return o, usageErr("--home not given and the user home directory could not be determined: %v", err)
		}
		home = h
	}
	absHome, err := filepath.Abs(home)
	if err != nil {
		return o, fmt.Errorf("--home %q: %w", home, err)
	}
	if requireHomeExists {
		fi, err := os.Stat(absHome)
		if err != nil {
			if os.IsNotExist(err) {
				return o, fmt.Errorf("--home %q does not exist", absHome)
			}
			return o, fmt.Errorf("--home %q: %w", absHome, err)
		}
		if !fi.IsDir() {
			return o, fmt.Errorf("--home %q is not a directory", absHome)
		}
	}
	absSystem := ""
	if system != "" {
		absSystem, err = filepath.Abs(system)
		if err != nil {
			return o, fmt.Errorf("--system %q: %w", system, err)
		}
		fi, err := os.Stat(absSystem)
		if err != nil {
			return o, fmt.Errorf("--system %q: %w", absSystem, err)
		}
		if !fi.IsDir() {
			return o, fmt.Errorf("--system %q is not a directory", absSystem)
		}
	}
	o = scanOpts{goos: goos, roots: roots{home: absHome, system: absSystem}, minSens: ms, asJSON: asJSON}
	return o, nil
}

// takePositionalHome lets the home root be given positionally, so
// "cleanvault scan /path --json" and "cleanvault scan --json /path" are both
// accepted. reorderFlags is what makes the trailing-flag form parse.
func takePositionalHome(fs *flag.FlagSet, home *string) error {
	switch fs.NArg() {
	case 0:
		return nil
	case 1:
		if *home != "" {
			return usageErr("home root given twice: --home %q and positional %q", *home, fs.Arg(0))
		}
		*home = fs.Arg(0)
		return nil
	default:
		return usageErr("unexpected argument %q", fs.Arg(1))
	}
}

func cmdScan(args []string) error {
	fs := newFlagSet("scan")
	osName, home, system, minSens, asJSON := bindCommon(fs)
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		return usageErr("%v", err)
	}
	if err := takePositionalHome(fs, home); err != nil {
		return err
	}
	o, err := buildOpts(*osName, *home, *system, *minSens, *asJSON, true)
	if err != nil {
		return err
	}
	results, err := resolve(catalog(o.goos), o.roots, o.minSens)
	if err != nil {
		return err
	}
	ef, files, bytes := totals(results)

	if o.asJSON {
		for i := range results {
			results[i].Matches = nil
		}
		return writeJSON(struct {
			Tool    string   `json:"tool"`
			Version string   `json:"version"`
			Command string   `json:"command"`
			OS      string   `json:"os"`
			Home    string   `json:"home"`
			System  string   `json:"system"`
			MinSens string   `json:"min_sensitivity"`
			Entries []Result `json:"entries"`
			Totals  struct {
				Entries      int    `json:"entries"`
				EntriesFound int    `json:"entries_found"`
				Files        int    `json:"files"`
				Bytes        int64  `json:"bytes"`
				BytesHuman   string `json:"bytes_human"`
			} `json:"totals"`
		}{
			Tool: toolName, Version: version, Command: "scan", OS: o.goos,
			Home: o.roots.home, System: o.roots.system, MinSens: o.minSens,
			Entries: results,
			Totals: struct {
				Entries      int    `json:"entries"`
				EntriesFound int    `json:"entries_found"`
				Files        int    `json:"files"`
				Bytes        int64  `json:"bytes"`
				BytesHuman   string `json:"bytes_human"`
			}{len(results), ef, files, bytes, humanBytes(bytes)},
		})
	}

	fmt.Println("CleanVault scan")
	fmt.Printf("  catalog:  %s (%d entries)\n", o.goos, len(results))
	fmt.Printf("  home:     %s\n", o.roots.home)
	fmt.Printf("  system:   %s\n", orNone(o.roots.system))
	fmt.Printf("  min-sens: %s\n\n", o.minSens)

	fmt.Printf("%-8s  %-6s  %6s  %10s  %-30s  %s\n", "STATUS", "SENS", "FILES", "SIZE", "APP / ARTIFACT", "PATH")
	fmt.Println(strings.Repeat("-", 118))
	for _, r := range results {
		size := "-"
		count := "-"
		if r.Status == statusFound {
			size = r.Human
			count = fmt.Sprintf("%d", r.Files)
		}
		fmt.Printf("%-8s  %-6s  %6s  %10s  %-30s  %s\n", r.Status, r.Entry.Sensitivity,
			count, size, trunc(r.Entry.App+" / "+r.Entry.Artifact, 30), r.Entry.Path)
	}
	fmt.Println()
	fmt.Printf("TOTAL: %d/%d catalog entries present, %d files, %s\n",
		ef, len(results), files, humanBytes(bytes))
	if ef > 0 {
		fmt.Println("Next: cleanvault clean --home <root> --quarantine <qdir>   (dry run; add --apply to move)")
	}
	return nil
}

func orNone(s string) string {
	if s == "" {
		return "(not set - machine-scope entries skipped)"
	}
	return s
}

// ---------------------------------------------------------------------------
// clean command
// ---------------------------------------------------------------------------

type moveRecord struct {
	Entry string `json:"entry"`
	Sens  string `json:"sensitivity"`
	Scope string `json:"scope"`
	From  string `json:"from"`
	To    string `json:"to"`
	Size  int64  `json:"size"`
	Moved bool   `json:"moved"`
	Error string `json:"error,omitempty"`
}

func cmdClean(args []string) error {
	fs := newFlagSet("clean")
	osName, home, system, minSens, asJSON := bindCommon(fs)
	quarantine := fs.String("quarantine", "", "quarantine directory")
	apply := fs.Bool("apply", false, "actually move files")
	if err := fs.Parse(reorderFlags(args, valueFlags)); err != nil {
		return usageErr("%v", err)
	}
	if err := takePositionalHome(fs, home); err != nil {
		return err
	}
	if strings.TrimSpace(*quarantine) == "" {
		return usageErr("clean requires --quarantine <dir>")
	}
	o, err := buildOpts(*osName, *home, *system, *minSens, *asJSON, true)
	if err != nil {
		return err
	}
	qdir, err := filepath.Abs(*quarantine)
	if err != nil {
		return fmt.Errorf("--quarantine %q: %w", *quarantine, err)
	}
	if err := checkQuarantine(qdir, o.roots); err != nil {
		return err
	}

	results, err := resolve(catalog(o.goos), o.roots, o.minSens)
	if err != nil {
		return err
	}

	var plan []moveRecord
	for _, r := range results {
		if r.Status != statusFound {
			continue
		}
		for _, m := range r.Matches {
			dest := filepath.Join(qdir, r.Entry.Scope, filepath.FromSlash(m.Rel))
			plan = append(plan, moveRecord{
				Entry: r.ID, Sens: r.Entry.Sensitivity, Scope: r.Entry.Scope,
				From: m.Abs, To: dest, Size: m.Size,
			})
		}
	}

	var totalBytes int64
	for _, p := range plan {
		totalBytes += p.Size
	}

	failures := 0
	if *apply {
		if err := os.MkdirAll(qdir, 0o700); err != nil {
			return fmt.Errorf("creating quarantine %q: %w", qdir, err)
		}
		for i := range plan {
			if err := quarantineFile(plan[i].From, plan[i].To); err != nil {
				plan[i].Error = err.Error()
				failures++
				continue
			}
			plan[i].Moved = true
		}
		if err := writeManifest(qdir, o, plan); err != nil {
			return fmt.Errorf("writing manifest: %w", err)
		}
	}

	moved := 0
	var movedBytes int64
	for _, p := range plan {
		if p.Moved {
			moved++
			movedBytes += p.Size
		}
	}

	if o.asJSON {
		err := writeJSON(struct {
			Tool       string       `json:"tool"`
			Version    string       `json:"version"`
			Command    string       `json:"command"`
			OS         string       `json:"os"`
			Home       string       `json:"home"`
			System     string       `json:"system"`
			Quarantine string       `json:"quarantine"`
			MinSens    string       `json:"min_sensitivity"`
			DryRun     bool         `json:"dry_run"`
			Planned    int          `json:"planned_files"`
			Bytes      int64        `json:"planned_bytes"`
			Moved      int          `json:"moved_files"`
			MovedBytes int64        `json:"moved_bytes"`
			Failures   int          `json:"failures"`
			Actions    []moveRecord `json:"actions"`
		}{
			toolName, version, "clean", o.goos, o.roots.home, o.roots.system, qdir,
			o.minSens, !*apply, len(plan), totalBytes, moved, movedBytes, failures, plan,
		})
		if err != nil {
			return err
		}
		if failures > 0 {
			return fmt.Errorf("%d file(s) could not be quarantined", failures)
		}
		return nil
	}

	if *apply {
		fmt.Println("CleanVault clean [APPLY - files are being moved]")
	} else {
		fmt.Println("CleanVault clean [DRY RUN - nothing is moved; re-run with --apply]")
	}
	fmt.Printf("  catalog:     %s\n", o.goos)
	fmt.Printf("  home:        %s\n", o.roots.home)
	fmt.Printf("  system:      %s\n", orNone(o.roots.system))
	fmt.Printf("  quarantine:  %s\n", qdir)
	fmt.Printf("  min-sens:    %s\n\n", o.minSens)

	for _, r := range results {
		if r.Status != statusFound {
			continue
		}
		fmt.Printf("  [%s] %s / %s  (%s)\n", r.Entry.Sensitivity, r.Entry.App, r.Entry.Artifact, r.Entry.Path)
		fmt.Printf("        %d file(s), %s\n", r.Files, r.Human)
	}
	if len(plan) == 0 {
		fmt.Println("  nothing matched.")
	}

	if len(plan) > 0 && len(plan) <= 60 {
		fmt.Println("\nFILES")
		for _, p := range plan {
			mark := "would move"
			if *apply && p.Moved {
				mark = "moved"
			} else if *apply {
				mark = "FAILED"
			}
			fmt.Printf("  %-10s %s\n             -> %s\n", mark, p.From, p.To)
			if p.Error != "" {
				fmt.Printf("             !! %s\n", p.Error)
			}
		}
	} else if len(plan) > 60 {
		fmt.Printf("\n(%d files; re-run with --json for the full list)\n", len(plan))
	}

	fmt.Println()
	if *apply {
		fmt.Printf("MOVED: %d/%d files, %s into %s\n", moved, len(plan), humanBytes(movedBytes), qdir)
		fmt.Printf("Manifest: %s\n", filepath.Join(qdir, "cleanvault-manifest.json"))
		fmt.Println("Nothing was deleted. Move files back from the quarantine to restore them.")
		if failures > 0 {
			return fmt.Errorf("%d file(s) could not be quarantined", failures)
		}
	} else {
		fmt.Printf("DRY RUN: %d files, %s would be quarantined. Nothing was moved.\n", len(plan), humanBytes(totalBytes))
		fmt.Println("Re-run the same command with --apply to move them.")
	}
	return nil
}

// checkQuarantine refuses quarantine locations that would make the tool eat its
// own output or destroy the roots it is scanning.
func checkQuarantine(qdir string, r roots) error {
	for _, root := range []string{r.home, r.system} {
		if root == "" {
			continue
		}
		if qdir == root {
			return fmt.Errorf("--quarantine %q is the same directory as the scan root; choose a location outside it", qdir)
		}
		if inside(qdir, root) {
			return fmt.Errorf("--quarantine %q is inside the scan root %q; choose a location outside it", qdir, root)
		}
		if inside(root, qdir) {
			return fmt.Errorf("scan root %q is inside --quarantine %q; choose a different quarantine location", root, qdir)
		}
	}
	if fi, err := os.Stat(qdir); err == nil && !fi.IsDir() {
		return fmt.Errorf("--quarantine %q exists and is not a directory", qdir)
	}
	return nil
}

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

// quarantineFile moves src to dst, creating parents. It never overwrites an
// existing quarantined file, and it never removes src unless the copy is
// complete and byte-for-byte the right size.
func quarantineFile(src, dst string) error {
	if _, err := os.Lstat(dst); err == nil {
		return fmt.Errorf("quarantine destination already exists: %s", dst)
	} else if !os.IsNotExist(err) {
		return err
	}
	if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
		return err
	}
	if err := os.Rename(src, dst); err == nil {
		return nil
	}
	// Cross-device or otherwise un-renamable: copy, verify, then remove.
	srcInfo, err := os.Lstat(src)
	if err != nil {
		return err
	}
	if !srcInfo.Mode().IsRegular() {
		return fmt.Errorf("refusing to move non-regular file %s", src)
	}
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
	if err != nil {
		return err
	}
	n, copyErr := io.Copy(out, in)
	if closeErr := out.Close(); copyErr == nil {
		copyErr = closeErr
	}
	if copyErr != nil {
		os.Remove(dst)
		return copyErr
	}
	if n != srcInfo.Size() {
		os.Remove(dst)
		return fmt.Errorf("short copy of %s (%d of %d bytes); source left untouched", src, n, srcInfo.Size())
	}
	return os.Remove(src)
}

func writeManifest(qdir string, o scanOpts, plan []moveRecord) error {
	type manifest struct {
		Tool       string       `json:"tool"`
		Version    string       `json:"version"`
		Timestamp  string       `json:"timestamp"`
		OS         string       `json:"os"`
		Home       string       `json:"home"`
		System     string       `json:"system"`
		Quarantine string       `json:"quarantine"`
		MinSens    string       `json:"min_sensitivity"`
		Actions    []moveRecord `json:"actions"`
	}
	m := manifest{
		Tool: toolName, Version: version, Timestamp: time.Now().UTC().Format(time.RFC3339),
		OS: o.goos, Home: o.roots.home, System: o.roots.system, Quarantine: qdir,
		MinSens: o.minSens, Actions: plan,
	}
	b, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	b = append(b, '\n')
	return os.WriteFile(filepath.Join(qdir, "cleanvault-manifest.json"), b, 0o600)
}
