// Command cleaninstall runs named, config-driven cleanup policies (each a
// target directory plus junk-glob patterns and an age threshold) and
// records every file it ever quarantines, across every run, in a
// persistent append-only JSON-lines ledger.
//
// SCOPE NOTE: the "CleanInstall" product concept is the Pro tier of the
// same App Janitor cluster as the sibling tool AppJanitor, and adds deep
// registry-aware uninstall, startup-item control, System Restore point
// management, and driver rollback on top of the base cleanup engine.
// 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 Pro-tier concept that genuinely is portable
// and useful without any of that: named cleanup policies read from a
// config file, each reusing AppJanitor's exact junk-pattern/age/
// quarantine-not-delete matching logic, plus the new piece -- a
// cumulative, append-only audit ledger across every policy run, ever, so
// "what has CleanInstall ever touched on this machine, and when" stays
// answerable months later. 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 (
	"bufio"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"time"
)

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:])
	case "ledger":
		runLedger(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "cleaninstall: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `CleanInstall - config-driven cleanup policies with a cumulative audit ledger

Usage:
  cleaninstall scan --policy <policies.json> <policy-name> [--json]
  cleaninstall clean --policy <policies.json> <policy-name> --quarantine <qdir> --ledger <ledger.jsonl> [--apply]
  cleaninstall ledger --ledger <ledger.jsonl> [--policy <name>] [--json]

Commands:
  scan    Read-only. Report what the named policy currently matches.
  clean   Move matches into --quarantine and append a record per file to
          --ledger (dry run unless --apply is given).
  ledger  Read-only. Summarize everything ever recorded in --ledger,
          across every clean run against every policy.

Policy file format (JSON):
  {
    "policies": [
      {
        "name": "temp-files",
        "target_dir": "/path/to/dir",
        "patterns": ["*.tmp", "*.bak"],
        "older_than": "0d"
      }
    ]
  }
  older_than uses the same digits+d/h/m suffix convention as AppJanitor;
  "0d" (or omitted) means no age filter -- only patterns are matched.

Run 'cleaninstall <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 `cleaninstall scan --policy p.json temp-files`
// or `cleaninstall scan temp-files --policy p.json` interchangeably.
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. Same convention as AppJanitor: "0d" parses to a zero duration,
// which callers treat as "no age filter".
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)
	}
}

// matchPattern returns the first pattern in patterns that matches the
// file's base name, or "" if none match. Same matching logic as
// AppJanitor.
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 file cleanup candidate matched by a
// policy.
type candidate struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
	Size   int64  `json:"size"`
}

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

// findMatches walks root and returns file candidates matching the
// policy's junk-glob patterns and/or age threshold. This is AppJanitor's
// exact matching logic (junk-pattern glob OR stale-age match), trimmed to
// the file half only -- policies have no empty-directory concept.
func findMatches(root string, patterns []string, maxAge time.Duration, hasMaxAge bool) ([]candidate, error) {
	var files []fileEntry

	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			fmt.Fprintf(os.Stderr, "cleaninstall: warning: %v\n", err)
			return nil
		}
		if path == root || d.IsDir() {
			return nil
		}
		info, ierr := d.Info()
		if ierr != nil {
			fmt.Fprintf(os.Stderr, "cleaninstall: 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, err
	}

	now := time.Now()
	var out []candidate
	for _, f := range files {
		if p := matchPattern(filepath.Base(f.path), patterns); p != "" {
			out = append(out, 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)
				out = append(out, candidate{Path: f.path, Reason: fmt.Sprintf("stale:%dd old", days), Size: f.size})
			}
		}
	}

	sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
	return out, 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(candidates []candidate) int64 {
	var total int64
	for _, c := range candidates {
		total += c.Size
		fmt.Printf("  %s  %s  (%s, running total %s)\n", c.Path, c.Reason, humanBytes(c.Size), humanBytes(total))
	}
	return total
}

// ---------------------------------------------------------------------
// Policy config

type policy struct {
	Name      string   `json:"name"`
	TargetDir string   `json:"target_dir"`
	Patterns  []string `json:"patterns"`
	OlderThan string   `json:"older_than"`
}

type policyConfig struct {
	Policies []policy `json:"policies"`
}

func loadPolicyConfig(path string) (*policyConfig, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("cannot read policy file %q: %w", path, err)
	}
	var cfg policyConfig
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("cannot parse policy file %q: %w", path, err)
	}
	if len(cfg.Policies) == 0 {
		return nil, fmt.Errorf("policy file %q defines no policies", path)
	}
	for i, p := range cfg.Policies {
		if p.Name == "" {
			return nil, fmt.Errorf("policy file %q: policy at index %d has no name", path, i)
		}
		if p.TargetDir == "" {
			return nil, fmt.Errorf("policy file %q: policy %q has no target_dir", path, p.Name)
		}
	}
	return &cfg, nil
}

func (c *policyConfig) find(name string) (*policy, error) {
	for i := range c.Policies {
		if c.Policies[i].Name == name {
			return &c.Policies[i], nil
		}
	}
	var names []string
	for _, p := range c.Policies {
		names = append(names, p.Name)
	}
	return nil, fmt.Errorf("unknown policy %q (known policies: %s)", name, strings.Join(names, ", "))
}

// resolve parses the policy's age threshold and returns the matching
// parameters ready for findMatches.
func (p *policy) resolve() (patterns []string, maxAge time.Duration, hasMaxAge bool, err error) {
	patterns = p.Patterns
	if p.OlderThan != "" {
		d, perr := parseAge(p.OlderThan)
		if perr != nil {
			return nil, 0, false, fmt.Errorf("policy %q: %w", p.Name, perr)
		}
		if d > 0 {
			maxAge, hasMaxAge = d, true
		}
	}
	return patterns, maxAge, hasMaxAge, nil
}

// ---------------------------------------------------------------------
// Ledger

// ledgerEntry is one JSON-lines record appended to the ledger file for
// every single file a `clean --apply` run quarantines.
type ledgerEntry struct {
	Path       string `json:"path"`
	Size       int64  `json:"size"`
	Policy     string `json:"policy"`
	Quarantine string `json:"quarantine"`
	Timestamp  string `json:"timestamp"`
}

// appendLedger opens the ledger file in append mode (creating it if
// needed) and writes one JSON line per entry. Opening with O_APPEND and
// never O_TRUNC is what makes this a true append across independent
// process invocations -- previous runs' records, from this or any other
// policy, are never touched.
func appendLedger(path string, entries []ledgerEntry) error {
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot open ledger %q: %w", path, err)
	}
	defer f.Close()
	enc := json.NewEncoder(f)
	for _, e := range entries {
		if err := enc.Encode(e); err != nil {
			return fmt.Errorf("cannot write ledger record: %w", err)
		}
	}
	return nil
}

// readLedger reads every record ever appended to path. A missing file is
// treated as an empty ledger (no clean --apply has run against it yet),
// not an error.
func readLedger(path string) ([]ledgerEntry, error) {
	f, err := os.Open(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, fmt.Errorf("cannot read ledger %q: %w", path, err)
	}
	defer f.Close()

	var entries []ledgerEntry
	scanner := bufio.NewScanner(f)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
	lineNo := 0
	for scanner.Scan() {
		lineNo++
		line := strings.TrimSpace(scanner.Text())
		if line == "" {
			continue
		}
		var e ledgerEntry
		if err := json.Unmarshal([]byte(line), &e); err != nil {
			return nil, fmt.Errorf("ledger %q: malformed record at line %d: %w", path, lineNo, err)
		}
		entries = append(entries, e)
	}
	if err := scanner.Err(); err != nil {
		return nil, fmt.Errorf("cannot read ledger %q: %w", path, err)
	}
	return entries, nil
}

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

func runScan(args []string) {
	args = reorderFlags(args, map[string]bool{"policy": true})
	fset := flag.NewFlagSet("scan", flag.ExitOnError)
	policyFile := fset.String("policy", "", "required: path to the policy JSON file")
	jsonOut := fset.Bool("json", false, "emit JSON instead of human-readable text")
	fset.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: cleaninstall scan --policy <policies.json> <policy-name> [--json]")
		fset.PrintDefaults()
	}
	fset.Parse(args)

	if *policyFile == "" {
		fmt.Fprintln(os.Stderr, "cleaninstall scan: --policy <policies.json> is required")
		fset.Usage()
		os.Exit(1)
	}
	if fset.NArg() < 1 {
		fmt.Fprintln(os.Stderr, "cleaninstall scan: missing <policy-name> argument")
		fset.Usage()
		os.Exit(1)
	}
	policyName := fset.Arg(0)

	cfg, err := loadPolicyConfig(*policyFile)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall scan: %v\n", err)
		os.Exit(1)
	}
	p, err := cfg.find(policyName)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall scan: %v\n", err)
		os.Exit(1)
	}
	if err := checkDir(p.TargetDir); err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall scan: policy %q: %v\n", p.Name, err)
		os.Exit(1)
	}

	patterns, maxAge, hasMaxAge, err := p.resolve()
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall scan: %v\n", err)
		os.Exit(1)
	}

	matches, err := findMatches(p.TargetDir, patterns, maxAge, hasMaxAge)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall scan: %v\n", err)
		os.Exit(1)
	}

	if *jsonOut {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(matches)
		return
	}

	fmt.Printf("Scanning policy %q (%s)\n", p.Name, p.TargetDir)
	if len(matches) == 0 {
		fmt.Println("  nothing matched")
		return
	}
	total := printCandidates(matches)
	fmt.Printf("\n%d files (%s reclaimable)\n", len(matches), humanBytes(total))
}

func runClean(args []string) {
	args = reorderFlags(args, map[string]bool{"policy": true, "quarantine": true, "ledger": true})
	fset := flag.NewFlagSet("clean", flag.ExitOnError)
	policyFile := fset.String("policy", "", "required: path to the policy JSON file")
	quarantine := fset.String("quarantine", "", "required with --apply: destination directory for quarantined files (relative path from target_dir is preserved)")
	ledgerPath := fset.String("ledger", "", "required with --apply: path to the cumulative JSON-lines audit ledger (appended to, never truncated)")
	apply := fset.Bool("apply", false, "actually quarantine matches and append to the ledger; default is a dry run")
	fset.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: cleaninstall clean --policy <policies.json> <policy-name> --quarantine <qdir> --ledger <ledger.jsonl> [--apply]")
		fset.PrintDefaults()
	}
	fset.Parse(args)

	if *policyFile == "" {
		fmt.Fprintln(os.Stderr, "cleaninstall clean: --policy <policies.json> is required")
		fset.Usage()
		os.Exit(1)
	}
	if fset.NArg() < 1 {
		fmt.Fprintln(os.Stderr, "cleaninstall clean: missing <policy-name> argument")
		fset.Usage()
		os.Exit(1)
	}
	policyName := fset.Arg(0)

	cfg, err := loadPolicyConfig(*policyFile)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall clean: %v\n", err)
		os.Exit(1)
	}
	p, err := cfg.find(policyName)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall clean: %v\n", err)
		os.Exit(1)
	}
	if err := checkDir(p.TargetDir); err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall clean: policy %q: %v\n", p.Name, err)
		os.Exit(1)
	}
	if *apply && *quarantine == "" {
		fmt.Fprintln(os.Stderr, "cleaninstall clean: --quarantine <dir> is required with --apply")
		fset.Usage()
		os.Exit(1)
	}
	if *apply && *ledgerPath == "" {
		fmt.Fprintln(os.Stderr, "cleaninstall clean: --ledger <path> is required with --apply")
		fset.Usage()
		os.Exit(1)
	}

	patterns, maxAge, hasMaxAge, err := p.resolve()
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall clean: %v\n", err)
		os.Exit(1)
	}

	matches, err := findMatches(p.TargetDir, patterns, maxAge, hasMaxAge)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall clean: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Cleaning policy %q (%s)\n", p.Name, p.TargetDir)
	if len(matches) == 0 {
		fmt.Println("  nothing to clean")
		return
	}
	total := printCandidates(matches)
	fmt.Printf("\n%d files (%s reclaimable)\n", len(matches), humanBytes(total))

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

	// Files are always quarantined (moved), never deleted: a mistaken
	// match is one `mv` back away from undone. Same as AppJanitor.
	var entries []ledgerEntry
	var movedBytes int64
	movedCount := 0
	now := time.Now().UTC().Format(time.RFC3339)
	for _, c := range matches {
		rel, err := filepath.Rel(p.TargetDir, c.Path)
		if err != nil {
			fmt.Fprintf(os.Stderr, "cleaninstall 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, "cleaninstall 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, "cleaninstall clean: cannot move %s: %v\n", c.Path, err)
			continue
		}
		movedBytes += c.Size
		movedCount++
		entries = append(entries, ledgerEntry{
			Path:       c.Path,
			Size:       c.Size,
			Policy:     p.Name,
			Quarantine: dest,
			Timestamp:  now,
		})
	}

	if len(entries) > 0 {
		if err := appendLedger(*ledgerPath, entries); err != nil {
			fmt.Fprintf(os.Stderr, "cleaninstall clean: %v\n", err)
			os.Exit(1)
		}
	}

	fmt.Printf("\nmoved %d files (%s) to %s, appended %d records to %s\n", movedCount, humanBytes(movedBytes), *quarantine, len(entries), *ledgerPath)
}

func runLedger(args []string) {
	args = reorderFlags(args, map[string]bool{"ledger": true, "policy": true})
	fset := flag.NewFlagSet("ledger", flag.ExitOnError)
	ledgerPath := fset.String("ledger", "", "required: path to the cumulative JSON-lines audit ledger")
	policyFilter := fset.String("policy", "", "restrict the summary to this policy name")
	jsonOut := fset.Bool("json", false, "emit JSON instead of human-readable text")
	fset.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage: cleaninstall ledger --ledger <ledger.jsonl> [--policy <name>] [--json]")
		fset.PrintDefaults()
	}
	fset.Parse(args)

	if *ledgerPath == "" {
		fmt.Fprintln(os.Stderr, "cleaninstall ledger: --ledger <path> is required")
		fset.Usage()
		os.Exit(1)
	}

	entries, err := readLedger(*ledgerPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "cleaninstall ledger: %v\n", err)
		os.Exit(1)
	}

	type policyTotal struct {
		Policy string `json:"policy"`
		Files  int    `json:"files"`
		Bytes  int64  `json:"bytes"`
	}
	byPolicy := map[string]*policyTotal{}
	var order []string
	var totalFiles int
	var totalBytes int64
	for _, e := range entries {
		if *policyFilter != "" && e.Policy != *policyFilter {
			continue
		}
		pt, ok := byPolicy[e.Policy]
		if !ok {
			pt = &policyTotal{Policy: e.Policy}
			byPolicy[e.Policy] = pt
			order = append(order, e.Policy)
		}
		pt.Files++
		pt.Bytes += e.Size
		totalFiles++
		totalBytes += e.Size
	}
	sort.Strings(order)

	var totals []policyTotal
	for _, name := range order {
		totals = append(totals, *byPolicy[name])
	}

	if *jsonOut {
		out := struct {
			TotalFiles int           `json:"total_files"`
			TotalBytes int64         `json:"total_bytes"`
			ByPolicy   []policyTotal `json:"by_policy"`
		}{totalFiles, totalBytes, totals}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		enc.Encode(out)
		return
	}

	if *policyFilter != "" {
		fmt.Printf("Ledger %s (policy %q)\n", *ledgerPath, *policyFilter)
	} else {
		fmt.Printf("Ledger %s (all policies)\n", *ledgerPath)
	}
	if totalFiles == 0 {
		fmt.Println("  no records")
		return
	}
	for _, pt := range totals {
		fmt.Printf("  %-20s %6d files  %10s\n", pt.Policy, pt.Files, humanBytes(pt.Bytes))
	}
	fmt.Printf("\ntotal: %d files ever quarantined (%s)\n", totalFiles, humanBytes(totalBytes))
}
