// Command appjanitor finds stale, junk-pattern, and empty-directory cleanup
// candidates in a directory tree and, on request, moves them into a
// quarantine directory (files) or removes them (empty directories only).
//
// SCOPE NOTE: the "App Janitor" product concept combines deep-uninstall
// leftover cleanup (finding files a Windows uninstaller left behind by
// cross-referencing the registry), startup-item control, restore-point
// management, and driver rollback. None of that is honestly implementable
// as a cross-platform, dependency-free Go CLI: it requires privileged,
// OS-specific APIs (the Windows registry, WMI, Task Scheduler, System
// Restore, Device Manager) with no stdlib equivalent on macOS/Linux. This
// prototype instead implements the part of the concept that genuinely is
// portable and useful without any of that: walking a directory tree to
// find junk-pattern files, stale (old) files, and empty directories, and
// safely clearing them out via a quarantine-based move (never a hard
// delete). Registry/uninstall integration, startup control, restore
// points, and driver rollback are out of scope for this build; see
// ../plan.md for the full product plan.
package main

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

const defaultPatterns = "*.tmp,*.bak,*.log,*~,.DS_Store,Thumbs.db"

func main() {
	if len(os.Args) < 2 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		os.Exit(0)
	case "scan":
		runScan(os.Args[2:])
	case "clean":
		runClean(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "appjanitor: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `AppJanitor - stale/junk cleanup candidate finder

Usage:
  appjanitor scan <dir> [--older-than 180d] [--pattern "*.tmp,*.bak,..."] [--empty-dirs] [--json]
  appjanitor clean <dir> --quarantine <qdir> [--older-than ...] [--pattern ...] [--empty-dirs] [--apply]

Commands:
  scan    Read-only. Walk <dir> and report cleanup candidates.
  clean   Move matched files into --quarantine (dry run unless --apply is given).
          Matched empty directories are removed directly (--apply only).

Flags:
  --older-than DUR     Flag files with mtime older than DUR, e.g. 180d, 12h, 45m
  --pattern LIST       Comma-separated glob patterns matched against the base filename
                        (default: `+defaultPatterns+`)
  --empty-dirs         Also report directories with no files anywhere in their subtree
  --json               (scan only) emit JSON instead of human-readable text
  --quarantine DIR     (clean, required) destination directory for quarantined files
  --apply              (clean only) actually move/remove matches; default is dry run

Run 'appjanitor <command> -h' for command-specific flag details.
`)
}

// reorderFlags moves all flag tokens (and their values, for flags listed in
// valueFlags) ahead of positional arguments, working around the stdlib
// flag package's rule that parsing stops at the first non-flag argument.
// This lets users write `appjanitor scan /some/dir --apply` naturally.
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...)
}

// humanBytes renders a byte count in human-readable units.
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])
}

// parseAge hand-parses a duration-like string with a digits+unit suffix
// where unit is d (days), h (hours), or m (minutes) — time.ParseDuration
// doesn't support days, which is the unit most useful for "stale file" work.
func parseAge(s string) (time.Duration, error) {
	if len(s) < 2 {
		return 0, fmt.Errorf("invalid duration %q (want e.g. 180d, 12h, 45m)", s)
	}
	unit := s[len(s)-1]
	numPart := s[:len(s)-1]
	n, err := strconv.Atoi(numPart)
	if err != nil || n < 0 {
		return 0, fmt.Errorf("invalid duration %q (want e.g. 180d, 12h, 45m)", s)
	}
	switch unit {
	case 'd':
		return time.Duration(n) * 24 * time.Hour, nil
	case 'h':
		return time.Duration(n) * time.Hour, nil
	case 'm':
		return time.Duration(n) * time.Minute, nil
	default:
		return 0, fmt.Errorf("invalid duration unit in %q (use d, h, or m)", s)
	}
}

func splitPatterns(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(p)
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}

// matchPattern returns the first pattern in patterns that matches the
// file's base name, or "" if none match.
func matchPattern(name string, patterns []string) string {
	for _, p := range patterns {
		if ok, _ := filepath.Match(p, name); ok {
			return p
		}
	}
	return ""
}

// candidate describes a single cleanup candidate: either a file (isDir
// false, with a size) or an empty directory (isDir true, size unused).
type candidate struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
	Size   int64  `json:"size,omitempty"`
	IsDir  bool   `json:"is_dir"`
}

type fileEntry struct {
	path    string
	size    int64
	modTime time.Time
}

// findCandidates walks root and returns file candidates (junk-pattern
// and/or stale matches) and, if includeEmptyDirs is set, empty-directory
// candidates (directories, other than root itself, containing no regular
// files anywhere in their subtree).
func findCandidates(root string, patterns []string, maxAge time.Duration, hasMaxAge bool, includeEmptyDirs bool) ([]candidate, []candidate, error) {
	var files []fileEntry
	var dirs []string

	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor: warning: %v\n", err)
			return nil
		}
		if path == root {
			return nil
		}
		if d.IsDir() {
			dirs = append(dirs, path)
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			fmt.Fprintf(os.Stderr, "appjanitor: warning: %v\n", ierr)
			return nil
		}
		files = append(files, fileEntry{path: path, size: info.Size(), modTime: info.ModTime()})
		return nil
	})
	if err != nil {
		return nil, nil, err
	}

	now := time.Now()
	var fileCandidates []candidate
	for _, f := range files {
		if p := matchPattern(filepath.Base(f.path), patterns); p != "" {
			fileCandidates = append(fileCandidates, candidate{Path: f.path, Reason: "junk-pattern:" + p, Size: f.size})
			continue
		}
		if hasMaxAge {
			age := now.Sub(f.modTime)
			if age > maxAge {
				days := int(age.Hours() / 24)
				fileCandidates = append(fileCandidates, candidate{Path: f.path, Reason: fmt.Sprintf("stale:%dd old", days), Size: f.size})
			}
		}
	}

	var dirCandidates []candidate
	if includeEmptyDirs {
		for _, dir := range dirs {
			hasFile := false
			prefix := dir + string(os.PathSeparator)
			for _, f := range files {
				if strings.HasPrefix(f.path, prefix) {
					hasFile = true
					break
				}
			}
			if !hasFile {
				dirCandidates = append(dirCandidates, candidate{Path: dir, Reason: "empty-dir", IsDir: true})
			}
		}
	}

	sort.Slice(fileCandidates, func(i, j int) bool { return fileCandidates[i].Path < fileCandidates[j].Path })
	sort.Slice(dirCandidates, func(i, j int) bool { return dirCandidates[i].Path < dirCandidates[j].Path })

	return fileCandidates, dirCandidates, nil
}

func checkDir(dir string) error {
	info, err := os.Stat(dir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("directory %q does not exist", dir)
		}
		return fmt.Errorf("cannot access %q: %w", dir, err)
	}
	if !info.IsDir() {
		return fmt.Errorf("%q is not a directory", dir)
	}
	return nil
}

func printCandidates(fileCandidates, dirCandidates []candidate) int64 {
	var total int64
	for _, c := range fileCandidates {
		total += c.Size
		fmt.Printf("  %-8s %s  (%s, running total %s)\n", "[file]", c.Path+"  "+c.Reason, humanBytes(c.Size), humanBytes(total))
	}
	for _, c := range dirCandidates {
		fmt.Printf("  %-8s %s  %s\n", "[dir]", c.Path, c.Reason)
	}
	return total
}

func printSummary(fileCandidates, dirCandidates []candidate, total int64) {
	fmt.Printf("\n%d files (%s reclaimable), %d empty dirs\n", len(fileCandidates), humanBytes(total), len(dirCandidates))
}

func runScan(args []string) {
	args = reorderFlags(args, map[string]bool{"older-than": true, "pattern": true})
	fset := flag.NewFlagSet("scan", flag.ExitOnError)
	olderThan := fset.String("older-than", "", "flag files with mtime older than this (e.g. 180d, 12h, 45m)")
	patternFlag := fset.String("pattern", defaultPatterns, "comma-separated glob patterns matched against the base filename")
	emptyDirs := fset.Bool("empty-dirs", false, "also report directories with no files anywhere in their subtree")
	jsonOut := fset.Bool("json", false, "emit JSON instead of human-readable text")
	fset.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: appjanitor scan <dir> [--older-than 180d] [--pattern \"*.tmp,*.bak,...\"] [--empty-dirs] [--json]")
		fset.PrintDefaults()
	}
	fset.Parse(args)

	if fset.NArg() < 1 {
		fmt.Fprintln(os.Stderr, "appjanitor scan: missing <dir> argument")
		fset.Usage()
		os.Exit(1)
	}
	dir := fset.Arg(0)
	if err := checkDir(dir); err != nil {
		fmt.Fprintf(os.Stderr, "appjanitor scan: %v\n", err)
		os.Exit(1)
	}

	patterns := splitPatterns(*patternFlag)
	var maxAge time.Duration
	hasMaxAge := false
	if *olderThan != "" {
		d, err := parseAge(*olderThan)
		if err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor scan: %v\n", err)
			os.Exit(1)
		}
		maxAge, hasMaxAge = d, true
	}

	fileCandidates, dirCandidates, err := findCandidates(dir, patterns, maxAge, hasMaxAge, *emptyDirs)
	if err != nil {
		fmt.Fprintf(os.Stderr, "appjanitor scan: %v\n", err)
		os.Exit(1)
	}

	if *jsonOut {
		all := append(append([]candidate{}, fileCandidates...), dirCandidates...)
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(all)
		return
	}

	fmt.Printf("Scanning %s\n", dir)
	total := printCandidates(fileCandidates, dirCandidates)
	printSummary(fileCandidates, dirCandidates, total)
}

func runClean(args []string) {
	args = reorderFlags(args, map[string]bool{"quarantine": true, "older-than": true, "pattern": true})
	fset := flag.NewFlagSet("clean", flag.ExitOnError)
	quarantine := fset.String("quarantine", "", "required: destination directory for quarantined files (relative path is preserved)")
	olderThan := fset.String("older-than", "", "flag files with mtime older than this (e.g. 180d, 12h, 45m)")
	patternFlag := fset.String("pattern", defaultPatterns, "comma-separated glob patterns matched against the base filename")
	emptyDirs := fset.Bool("empty-dirs", false, "also remove directories with no files anywhere in their subtree")
	apply := fset.Bool("apply", false, "actually move/remove matches; default is a dry run")
	fset.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: appjanitor clean <dir> --quarantine <qdir> [--older-than ...] [--pattern ...] [--empty-dirs] [--apply]")
		fset.PrintDefaults()
	}
	fset.Parse(args)

	if fset.NArg() < 1 {
		fmt.Fprintln(os.Stderr, "appjanitor clean: missing <dir> argument")
		fset.Usage()
		os.Exit(1)
	}
	dir := fset.Arg(0)
	if err := checkDir(dir); err != nil {
		fmt.Fprintf(os.Stderr, "appjanitor clean: %v\n", err)
		os.Exit(1)
	}
	if *quarantine == "" {
		fmt.Fprintln(os.Stderr, "appjanitor clean: --quarantine <dir> is required")
		fset.Usage()
		os.Exit(1)
	}

	patterns := splitPatterns(*patternFlag)
	var maxAge time.Duration
	hasMaxAge := false
	if *olderThan != "" {
		d, err := parseAge(*olderThan)
		if err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor clean: %v\n", err)
			os.Exit(1)
		}
		maxAge, hasMaxAge = d, true
	}

	fileCandidates, dirCandidates, err := findCandidates(dir, patterns, maxAge, hasMaxAge, *emptyDirs)
	if err != nil {
		fmt.Fprintf(os.Stderr, "appjanitor clean: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Scanning %s\n", dir)
	total := printCandidates(fileCandidates, dirCandidates)
	printSummary(fileCandidates, dirCandidates, total)

	if !*apply {
		fmt.Println("\n(dry run — re-run with --apply)")
		return
	}

	// Files are always quarantined (moved), never deleted: a mistaken match
	// should be one `mv` back away from undone. Empty directories are
	// removed directly instead of being quarantined -- there is no content
	// inside them to lose, so os.Remove is used rather than a move, and
	// removing an empty directory is reversible in spirit (an empty
	// directory can always simply be recreated).
	var movedBytes int64
	movedCount := 0
	for _, c := range fileCandidates {
		rel, err := filepath.Rel(dir, c.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor clean: cannot compute relative path for %s: %v\n", c.Path, err)
			continue
		}
		dest := filepath.Join(*quarantine, rel)
		if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor clean: cannot create quarantine dir for %s: %v\n", c.Path, err)
			continue
		}
		if err := os.Rename(c.Path, dest); err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor clean: cannot move %s: %v\n", c.Path, err)
			continue
		}
		movedBytes += c.Size
		movedCount++
	}

	// Remove deepest directories first so a parent that is only empty
	// because its (already-listed) empty child was just removed succeeds.
	sort.Slice(dirCandidates, func(i, j int) bool {
		return strings.Count(dirCandidates[i].Path, string(os.PathSeparator)) > strings.Count(dirCandidates[j].Path, string(os.PathSeparator))
	})
	removedCount := 0
	for _, c := range dirCandidates {
		if err := os.Remove(c.Path); err != nil {
			fmt.Fprintf(os.Stderr, "appjanitor clean: cannot remove %s: %v\n", c.Path, err)
			continue
		}
		removedCount++
	}

	fmt.Printf("\nmoved %d files (%s) to %s, removed %d empty dirs\n", movedCount, humanBytes(movedBytes), *quarantine, removedCount)
}
