// Command freshdesk plans fleet-wide disk reclamation.
//
// Each machine runs "freshdesk survey" and produces a report describing how
// much space sits in well-known reclaimable categories. "freshdesk fleet"
// merges every report so an IT team can see where the reclaimable space is
// across all machines, which machines are worst, and what a policy would
// return if it were run everywhere. "freshdesk plan" turns one category into
// an approvable proposal.
//
// FreshDesk only MEASURES. It never deletes, moves or truncates anything.
package main

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

const (
	toolName      = "freshdesk"
	toolVersion   = "1.0.0"
	formatVersion = 1
)

// ---------------------------------------------------------------------------
// Shared Techlosoft UX 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])
}

// ---------------------------------------------------------------------------
// Built-in reclamation catalog.
//
// Matching rules, in full:
//
//   - dirs  patterns are matched, case-insensitively, against every directory
//     component of a file's path BELOW the survey root. One matching
//     component claims the file.
//   - files patterns are matched, case-insensitively, against the file's base
//     name.
//   - Categories are evaluated in the order below and the FIRST match claims
//     the file, so no byte is ever counted twice.
//   - empty_dirs has no patterns: it matches any directory below a root that
//     contains no entries at all, and counts the directory's own size.
//   - Symbolic links are never followed and never counted.
//
// ---------------------------------------------------------------------------

type catalogEntry struct {
	category string
	desc     string
	dirs     []string
	files    []string
}

var catalog = []catalogEntry{
	{
		category: "temp_files",
		desc:     "scratch files and interrupted downloads",
		dirs:     []string{"tmp", "temp", ".tmp", "temporary internet files"},
		files:    []string{"*.tmp", "*.temp", "*.part", "*.partial", "*.crdownload", "~$*"},
	},
	{
		category: "caches",
		desc:     "regenerable application and browser caches",
		dirs:     []string{"cache", "caches", ".cache", "cache2", "cacheddata", "cachestorage"},
		files:    []string{"*.cache"},
	},
	{
		category: "logs",
		desc:     "application logs and rotated log archives",
		dirs:     []string{"logs", "log", "diagnosticlogs"},
		files:    []string{"*.log", "*.log.[0-9]", "*.log.gz", "*.log.old", "*.log.[0-9].gz"},
	},
	{
		category: "crash_dumps",
		desc:     "crash reports, minidumps and core files",
		dirs:     []string{"crashdumps", "crashes", "crashreports", "crash reports", "diagnosticreports", "minidumps"},
		files:    []string{"*.dmp", "*.mdmp", "*.dump", "*.crash", "core.[0-9]*"},
	},
	{
		category: "old_installers",
		desc:     "downloaded installer packages kept after install",
		dirs:     []string{"installers", "installer cache", "packagecache"},
		files:    []string{"*.msi", "*.msp", "*.dmg", "*.pkg", "*.deb", "*.rpm", "*.appx", "*.msix", "*setup*.exe", "*install*.exe"},
	},
	{
		category: "empty_dirs",
		desc:     "directories left behind with no entries at all",
	},
}

const emptyDirCategory = "empty_dirs"

func lookupCategory(name string) (catalogEntry, bool) {
	for _, c := range catalog {
		if c.category == name {
			return c, true
		}
	}
	return catalogEntry{}, false
}

func categoryNames() []string {
	names := make([]string, 0, len(catalog))
	for _, c := range catalog {
		names = append(names, c.category)
	}
	return names
}

func matchAny(patterns []string, name string) bool {
	lower := strings.ToLower(name)
	for _, p := range patterns {
		if ok, err := filepath.Match(p, lower); err == nil && ok {
			return true
		}
	}
	return false
}

// classify returns the category that claims a file, or "" for a file that is
// not reclaimable under any built-in rule. dirs are the path components below
// the root, base is the file's own name.
func classify(entries []catalogEntry, dirs []string, base string) string {
	for _, e := range entries {
		if e.category == emptyDirCategory {
			continue
		}
		for _, d := range dirs {
			if matchAny(e.dirs, d) {
				return e.category
			}
		}
		if matchAny(e.files, base) {
			return e.category
		}
	}
	return ""
}

// ---------------------------------------------------------------------------
// Data model.
// ---------------------------------------------------------------------------

// Item is one reclaimable file (or one empty directory).
type Item struct {
	Path  string `json:"path"`
	Bytes int64  `json:"bytes"`
	MTime string `json:"mtime"`
}

// CategoryReport is one reclamation category measured on one machine.
type CategoryReport struct {
	Category      string `json:"category"`
	Description   string `json:"description"`
	Files         int    `json:"files"`
	Bytes         int64  `json:"bytes"`
	OldestPath    string `json:"oldest_path,omitempty"`
	OldestMTime   string `json:"oldest_mtime,omitempty"`
	OldestAgeDays int    `json:"oldest_age_days"`
	NewestPath    string `json:"newest_path,omitempty"`
	NewestMTime   string `json:"newest_mtime,omitempty"`
	NewestAgeDays int    `json:"newest_age_days"`
	Items         []Item `json:"items"`
}

// Report is one machine's survey result.
type Report struct {
	Tool          string           `json:"tool"`
	ToolVersion   string           `json:"tool_version"`
	FormatVersion int              `json:"format_version"`
	Machine       string           `json:"machine"`
	Roots         []string         `json:"roots"`
	SurveyedAt    string           `json:"surveyed_at"`
	TotalFiles    int              `json:"total_files"`
	TotalBytes    int64            `json:"total_bytes"`
	Categories    []CategoryReport `json:"categories"`
	Errors        []string         `json:"errors"`

	source string
}

// SkippedReport records a report file that could not be used, and why.
type SkippedReport struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

// MachineSummary is one machine's line in the fleet rollup.
type MachineSummary struct {
	Machine     string  `json:"machine"`
	Source      string  `json:"source"`
	SurveyedAt  string  `json:"surveyed_at"`
	Files       int     `json:"files"`
	Bytes       int64   `json:"bytes"`
	SharePct    float64 `json:"share_pct"`
	TopCategory string  `json:"top_category"`
	TopBytes    int64   `json:"top_category_bytes"`
	Flagged     bool    `json:"flagged"`
}

// CategoryTotal is one category summed across the whole fleet.
type CategoryTotal struct {
	Category string  `json:"category"`
	Machines int     `json:"machines"`
	Files    int     `json:"files"`
	Bytes    int64   `json:"bytes"`
	SharePct float64 `json:"share_pct"`
}

// TopItem is one of the largest single reclaimable items in the fleet.
type TopItem struct {
	Machine  string `json:"machine"`
	Category string `json:"category"`
	Path     string `json:"path"`
	Bytes    int64  `json:"bytes"`
	MTime    string `json:"mtime"`
	AgeDays  int    `json:"age_days"`
}

// FleetSummary is the headline answer for the whole fleet.
type FleetSummary struct {
	ReportsGiven    int    `json:"reports_given"`
	MachinesMerged  int    `json:"machines_merged"`
	ReportsSkipped  int    `json:"reports_skipped"`
	TotalFiles      int    `json:"total_files"`
	TotalBytes      int64  `json:"total_bytes"`
	MeanBytes       int64  `json:"mean_bytes_per_machine"`
	MedianBytes     int64  `json:"median_bytes_per_machine"`
	WorstMachine    string `json:"worst_machine"`
	WorstBytes      int64  `json:"worst_machine_bytes"`
	ThresholdBytes  int64  `json:"threshold_bytes"`
	ThresholdSet    bool   `json:"threshold_set"`
	FlaggedMachines int    `json:"flagged_machines"`
}

// FleetResult is the complete machine-readable fleet rollup.
type FleetResult struct {
	Tool          string           `json:"tool"`
	ToolVersion   string           `json:"tool_version"`
	FormatVersion int              `json:"format_version"`
	MergedAt      string           `json:"merged_at"`
	Machines      []MachineSummary `json:"machines"`
	Categories    []CategoryTotal  `json:"categories"`
	TopItems      []TopItem        `json:"top_items"`
	Flagged       []string         `json:"flagged"`
	Skipped       []SkippedReport  `json:"skipped"`
	Summary       FleetSummary     `json:"summary"`
}

// PlanMachine is one machine's share of a proposed cleanup.
type PlanMachine struct {
	Machine       string `json:"machine"`
	Source        string `json:"source"`
	Files         int    `json:"files"`
	Bytes         int64  `json:"bytes"`
	SkippedFiles  int    `json:"skipped_too_new_files"`
	SkippedBytes  int64  `json:"skipped_too_new_bytes"`
	OldestPath    string `json:"oldest_path,omitempty"`
	OldestAgeDays int    `json:"oldest_age_days"`
}

// PlanSummary is the total a fleet-wide cleanup of one category would return.
type PlanSummary struct {
	MachinesMerged  int   `json:"machines_merged"`
	MachinesWithAny int   `json:"machines_with_recoverable"`
	ReportsSkipped  int   `json:"reports_skipped"`
	TotalFiles      int   `json:"total_files"`
	TotalBytes      int64 `json:"total_bytes"`
	HeldBackFiles   int   `json:"held_back_files"`
	HeldBackBytes   int64 `json:"held_back_bytes"`
}

// PlanResult is the complete machine-readable cleanup proposal.
type PlanResult struct {
	Tool          string          `json:"tool"`
	ToolVersion   string          `json:"tool_version"`
	FormatVersion int             `json:"format_version"`
	PlannedAt     string          `json:"planned_at"`
	Category      string          `json:"category"`
	Description   string          `json:"description"`
	MinAge        string          `json:"min_age"`
	MinAgeDays    float64         `json:"min_age_days"`
	Machines      []PlanMachine   `json:"machines"`
	Skipped       []SkippedReport `json:"skipped"`
	Summary       PlanSummary     `json:"summary"`
	Note          string          `json:"note"`
}

const remediationNote = "FreshDesk never deletes anything. Hand this plan to AppJanitor (per-machine cleanup) or CleanInstall (policy cleanup with a ledger) to carry it out."

// ---------------------------------------------------------------------------
// Usage.
// ---------------------------------------------------------------------------

const usageText = `freshdesk ` + toolVersion + ` - fleet disk-reclamation planning (Techlosoft App Janitor).

USAGE
  freshdesk survey --machine <name> --roots <dir>[,<dir>...] --out <report.json>
                   [--categories <a,b,...>] [--json]
  freshdesk fleet  <report.json> [more.json ...] [--dir <reports-dir>]
                   [--threshold <size>] [--top <n>] [--json]
  freshdesk plan   --dir <reports-dir> [report.json ...] --category <name>
                   [--min-age 30d] [--json]
  freshdesk help | -h | --help

COMMANDS
  survey  The agent each machine runs. Measures - without deleting anything -
          how much space sits in the built-in reclaimable categories under one
          or more roots, and writes a JSON report. Per category it records the
          file count, total bytes, and the oldest and newest item.

  fleet   Merges many machine reports into the answer an IT team actually
          wants: total reclaimable space across the fleet, a per-machine table
          sorted worst-first, a per-category breakdown, and the largest single
          items anywhere in the fleet. Machines above --threshold are flagged.

  plan    Answers "if we cleaned this one category everywhere, what do we get
          back?" - per machine and in total - so it can be approved before
          anyone touches a disk. --min-age holds back anything too recent.

SURVEY FLAGS
  --machine <name>    Machine label recorded in the report. Required.
  --roots <dirs>      Comma-separated list of directories to survey. Required.
  --out <file>        Write the JSON report here. Required; "-" means stdout.
  --categories <list> Comma-separated subset of categories to measure.
                      Default: all of them.
  --json              Print the JSON report to stdout as well as writing it.

FLEET FLAGS
  --dir <dir>         Also ingest every *.json file in this directory.
  --threshold <size>  Flag machines whose reclaimable space is at or above
                      this size. Accepts bytes or a suffix: 500KB, 40MB, 2GiB.
  --top <n>           How many largest single items to list. Default 10.
  --json              Emit the full rollup as JSON instead of tables.

PLAN FLAGS
  --dir <dir>         Directory of report JSON files. Required unless report
                      paths are given as positional arguments.
  --category <name>   The one category to plan a cleanup for. Required.
  --min-age <age>     Only count items at least this old. Accepts 12h, 30d,
                      6w. Default 0 (count everything).
  --json              Emit the full plan as JSON instead of a table.

CATEGORIES (built-in, documented, not exhaustive)
  temp_files      scratch files and interrupted downloads
  caches          regenerable application and browser caches
  logs            application logs and rotated log archives
  crash_dumps     crash reports, minidumps and core files
  old_installers  downloaded installer packages kept after install
  empty_dirs      directories left behind with no entries at all

  Categories are evaluated in the order above and the first match claims a
  file, so no byte is counted twice. Matching is case-insensitive, symbolic
  links are never followed, and a file reachable through two roots is counted
  once.

EXIT CODES
  0  success
  1  usage error, unreadable input, or no usable reports
  2  fleet completed and at least one machine is at or above --threshold

FreshDesk only MEASURES and PLANS. It has no --apply, no delete path, and
never modifies anything except the file named by --out. Remediation is the job
of AppJanitor and CleanInstall.
`

func usage(w io.Writer) {
	fmt.Fprint(w, usageText)
}

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

func failUsage(format string, args ...any) {
	fmt.Fprintf(os.Stderr, toolName+": "+format+"\n\n", args...)
	usage(os.Stderr)
	os.Exit(1)
}

func isHelp(a string) bool {
	return a == "-h" || a == "--help" || a == "help" || a == "-help"
}

// ---------------------------------------------------------------------------
// main.
// ---------------------------------------------------------------------------

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// 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)
	}
	if isHelp(args[0]) {
		usage(os.Stdout)
		os.Exit(0)
	}
	switch args[0] {
	case "survey":
		cmdSurvey(args[1:])
	case "fleet":
		cmdFleet(args[1:])
	case "plan":
		cmdPlan(args[1:])
	default:
		failUsage("unknown command %q", args[0])
	}
}

// newFlagSet builds a flag set that never prints its own usage, so every
// error path funnels through failUsage.
func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(io.Discard)
	fs.Usage = func() {}
	return fs
}

func containsHelp(args []string) bool {
	for _, a := range args {
		if isHelp(a) {
			return true
		}
	}
	return false
}

// splitList splits a comma-separated flag value, dropping empty fields.
func splitList(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		p = strings.TrimSpace(p)
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}

// ageDays converts a modification time into a whole number of days, rounded to
// the NEAREST day so that clock skew and DST shifts of a few hours do not move
// a "400 days ago" artifact to 399.
func ageDays(mod, now time.Time) int {
	d := now.Sub(mod).Hours() / 24
	if d < 0 {
		return 0
	}
	return int(math.Round(d))
}

// parseSize reads a byte size, with or without a unit suffix. Both decimal
// (KB, MB, GB) and binary (KiB, MiB, GiB) suffixes are accepted; a bare number
// is bytes.
func parseSize(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, errors.New("empty size")
	}
	units := []struct {
		suffix string
		mult   float64
	}{
		{"kib", 1 << 10}, {"mib", 1 << 20}, {"gib", 1 << 30}, {"tib", 1 << 40},
		{"kb", 1e3}, {"mb", 1e6}, {"gb", 1e9}, {"tb", 1e12},
		{"k", 1 << 10}, {"m", 1 << 20}, {"g", 1 << 30}, {"t", 1 << 40},
		{"b", 1},
	}
	lower := strings.ToLower(t)
	mult := float64(1)
	num := lower
	for _, u := range units {
		if strings.HasSuffix(lower, u.suffix) {
			mult = u.mult
			num = strings.TrimSpace(strings.TrimSuffix(lower, u.suffix))
			break
		}
	}
	if num == "" {
		return 0, fmt.Errorf("no number in size %q", s)
	}
	v, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, fmt.Errorf("not a size: %q", s)
	}
	if v < 0 {
		return 0, fmt.Errorf("size must not be negative: %q", s)
	}
	return int64(v * mult), nil
}

// parseAge reads an age such as 12h, 30d or 6w. A bare number means days.
func parseAge(s string) (time.Duration, error) {
	t := strings.ToLower(strings.TrimSpace(s))
	if t == "" {
		return 0, errors.New("empty age")
	}
	mult := 24 * time.Hour
	num := t
	switch {
	case strings.HasSuffix(t, "h"):
		mult, num = time.Hour, strings.TrimSuffix(t, "h")
	case strings.HasSuffix(t, "d"):
		mult, num = 24*time.Hour, strings.TrimSuffix(t, "d")
	case strings.HasSuffix(t, "w"):
		mult, num = 7*24*time.Hour, strings.TrimSuffix(t, "w")
	}
	num = strings.TrimSpace(num)
	if num == "" {
		return 0, fmt.Errorf("no number in age %q", s)
	}
	v, err := strconv.ParseFloat(num, 64)
	if err != nil {
		return 0, fmt.Errorf("not an age: %q (want e.g. 12h, 30d, 6w)", s)
	}
	if v < 0 {
		return 0, fmt.Errorf("age must not be negative: %q", s)
	}
	return time.Duration(v * float64(mult)), nil
}

// ---------------------------------------------------------------------------
// survey.
// ---------------------------------------------------------------------------

func cmdSurvey(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{"machine": true, "roots": true, "out": true, "categories": true})

	fs := newFlagSet("survey")
	machine := fs.String("machine", "", "machine name")
	roots := fs.String("roots", "", "comma-separated survey roots")
	out := fs.String("out", "", "report output path")
	cats := fs.String("categories", "", "comma-separated category subset")
	asJSON := fs.Bool("json", false, "print the JSON report to stdout")
	if err := fs.Parse(args); err != nil {
		failUsage("survey: %v", err)
	}
	if rest := fs.Args(); len(rest) > 0 {
		failUsage("survey: unexpected argument %q", rest[0])
	}
	if *machine == "" {
		failUsage("survey: --machine is required")
	}
	if *roots == "" {
		failUsage("survey: --roots is required")
	}
	if *out == "" {
		failUsage("survey: --out is required (use \"-\" for stdout)")
	}

	rootList := splitList(*roots)
	if len(rootList) == 0 {
		failUsage("survey: --roots listed no directories")
	}
	for _, r := range rootList {
		info, err := os.Stat(r)
		if err != nil {
			fail("survey: cannot read root %s: %v", r, err)
		}
		if !info.IsDir() {
			fail("survey: root %s is not a directory", r)
		}
	}

	selected := catalog
	if *cats != "" {
		names := splitList(*cats)
		if len(names) == 0 {
			failUsage("survey: --categories listed no categories")
		}
		selected = nil
		seen := map[string]bool{}
		for _, n := range names {
			e, ok := lookupCategory(n)
			if !ok {
				fail("survey: unknown category %q (known: %s)", n, strings.Join(categoryNames(), ", "))
			}
			if seen[n] {
				continue
			}
			seen[n] = true
			selected = append(selected, e)
		}
	}

	rep := surveyRoots(*machine, rootList, selected, time.Now())

	data, err := json.MarshalIndent(rep, "", "  ")
	if err != nil {
		fail("survey: encoding report: %v", err)
	}
	data = append(data, '\n')

	if *out != "-" {
		if dir := filepath.Dir(*out); dir != "" && dir != "." {
			if err := os.MkdirAll(dir, 0o755); err != nil {
				fail("survey: creating %s: %v", dir, err)
			}
		}
		if err := os.WriteFile(*out, data, 0o644); err != nil {
			fail("survey: writing %s: %v", *out, err)
		}
	}
	switch {
	case *asJSON || *out == "-":
		os.Stdout.Write(data)
	default:
		printSurvey(os.Stdout, rep, *out)
	}
}

// surveyRoots measures every selected category beneath every root. It only
// reads: it opens directories to list them and stats files. Nothing is
// created, modified, moved or removed.
func surveyRoots(machine string, roots []string, selected []catalogEntry, now time.Time) *Report {
	rep := &Report{
		Tool:          toolName,
		ToolVersion:   toolVersion,
		FormatVersion: formatVersion,
		Machine:       machine,
		SurveyedAt:    now.UTC().Format(time.RFC3339),
		Errors:        []string{},
	}

	byCat := make(map[string]*CategoryReport, len(selected))
	order := make([]string, 0, len(selected))
	wantEmpty := false
	for _, e := range selected {
		byCat[e.category] = &CategoryReport{
			Category:    e.category,
			Description: e.desc,
			Items:       []Item{},
		}
		order = append(order, e.category)
		if e.category == emptyDirCategory {
			wantEmpty = true
		}
	}

	seen := make(map[string]bool)

	for _, root := range roots {
		abs, err := filepath.Abs(root)
		if err != nil {
			abs = root
		}
		rep.Roots = append(rep.Roots, filepath.ToSlash(abs))

		filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error {
			if err != nil {
				rep.Errors = append(rep.Errors, fmt.Sprintf("%s: %v", p, err))
				if d != nil && d.IsDir() {
					return fs.SkipDir
				}
				return nil
			}
			if d.Type()&os.ModeSymlink != 0 {
				return nil
			}
			if d.IsDir() {
				if p == abs || !wantEmpty {
					return nil
				}
				empty, derr := dirIsEmpty(p)
				if derr != nil {
					rep.Errors = append(rep.Errors, fmt.Sprintf("%s: %v", p, derr))
					return nil
				}
				if !empty {
					return nil
				}
				fi, ferr := d.Info()
				if ferr != nil {
					return nil
				}
				addItem(byCat[emptyDirCategory], seen, p, fi.Size(), fi.ModTime())
				return nil
			}
			if !d.Type().IsRegular() {
				return nil
			}
			rel, rerr := filepath.Rel(abs, p)
			if rerr != nil {
				rel = filepath.Base(p)
			}
			parts := strings.Split(filepath.ToSlash(rel), "/")
			base := parts[len(parts)-1]
			cat := classify(selected, parts[:len(parts)-1], base)
			if cat == "" {
				return nil
			}
			fi, ferr := d.Info()
			if ferr != nil {
				rep.Errors = append(rep.Errors, fmt.Sprintf("%s: %v", p, ferr))
				return nil
			}
			addItem(byCat[cat], seen, p, fi.Size(), fi.ModTime())
			return nil
		})
	}

	for _, name := range order {
		cr := byCat[name]
		sort.Slice(cr.Items, func(i, j int) bool { return cr.Items[i].Path < cr.Items[j].Path })
		finalize(cr, now)
		rep.Categories = append(rep.Categories, *cr)
		rep.TotalFiles += cr.Files
		rep.TotalBytes += cr.Bytes
	}
	return rep
}

// dirIsEmpty reports whether a directory has no entries at all. It opens the
// directory read-only and reads at most one name.
func dirIsEmpty(path string) (bool, error) {
	f, err := os.Open(path)
	if err != nil {
		return false, err
	}
	defer f.Close()
	names, err := f.Readdirnames(1)
	if err != nil && err != io.EOF {
		return false, err
	}
	return len(names) == 0, nil
}

func addItem(cr *CategoryReport, seen map[string]bool, path string, size int64, mod time.Time) {
	if cr == nil || seen[path] {
		return
	}
	seen[path] = true
	cr.Files++
	cr.Bytes += size
	cr.Items = append(cr.Items, Item{
		Path:  filepath.ToSlash(path),
		Bytes: size,
		MTime: mod.UTC().Format(time.RFC3339),
	})
}

func finalize(cr *CategoryReport, now time.Time) {
	var oldest, newest time.Time
	var oldestPath, newestPath string
	for _, it := range cr.Items {
		mt, err := time.Parse(time.RFC3339, it.MTime)
		if err != nil {
			continue
		}
		if oldest.IsZero() || mt.Before(oldest) {
			oldest, oldestPath = mt, it.Path
		}
		if newest.IsZero() || mt.After(newest) {
			newest, newestPath = mt, it.Path
		}
	}
	if oldestPath != "" {
		cr.OldestPath = oldestPath
		cr.OldestMTime = oldest.UTC().Format(time.RFC3339)
		cr.OldestAgeDays = ageDays(oldest, now)
	}
	if newestPath != "" {
		cr.NewestPath = newestPath
		cr.NewestMTime = newest.UTC().Format(time.RFC3339)
		cr.NewestAgeDays = ageDays(newest, now)
	}
}

func printSurvey(w io.Writer, rep *Report, outPath string) {
	fmt.Fprintf(w, "FreshDesk survey - machine %q\n", rep.Machine)
	fmt.Fprintf(w, "Roots:       %s\n", strings.Join(rep.Roots, ", "))
	fmt.Fprintf(w, "Surveyed at: %s\n\n", rep.SurveyedAt)

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "CATEGORY\tFILES\tBYTES\tSIZE\tOLDEST(d)\tNEWEST(d)")
	for _, c := range rep.Categories {
		oldest, newest := "-", "-"
		if c.Files > 0 {
			oldest = strconv.Itoa(c.OldestAgeDays)
			newest = strconv.Itoa(c.NewestAgeDays)
		}
		fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\t%s\n", c.Category, c.Files, c.Bytes, humanBytes(c.Bytes), oldest, newest)
	}
	fmt.Fprintf(tw, "TOTAL\t%d\t%d\t%s\t\t\n", rep.TotalFiles, rep.TotalBytes, humanBytes(rep.TotalBytes))
	tw.Flush()

	if len(rep.Errors) > 0 {
		fmt.Fprintf(w, "\nUNREADABLE PATHS (%d)\n", len(rep.Errors))
		for _, e := range rep.Errors {
			fmt.Fprintf(w, "  %s\n", e)
		}
	}
	if outPath != "" && outPath != "-" {
		fmt.Fprintf(w, "\nReport written to %s\n", outPath)
	}
	fmt.Fprintf(w, "Nothing was deleted; FreshDesk only measures.\n")
}

// ---------------------------------------------------------------------------
// Report ingestion, shared by fleet and plan.
// ---------------------------------------------------------------------------

// gatherReportPaths merges positional report paths with --dir contents,
// de-duplicating by absolute path while preserving order.
func gatherReportPaths(positional []string, dir string) ([]string, error) {
	var paths []string
	seen := make(map[string]bool)
	add := func(p string) {
		key := p
		if abs, err := filepath.Abs(p); err == nil {
			key = abs
		}
		if seen[key] {
			return
		}
		seen[key] = true
		paths = append(paths, p)
	}
	for _, p := range positional {
		add(p)
	}
	if dir != "" {
		info, err := os.Stat(dir)
		if err != nil {
			return nil, fmt.Errorf("cannot read --dir %s: %w", dir, err)
		}
		if !info.IsDir() {
			return nil, fmt.Errorf("--dir %s is not a directory", dir)
		}
		matches, err := filepath.Glob(filepath.Join(dir, "*.json"))
		if err != nil {
			return nil, fmt.Errorf("listing %s: %w", dir, err)
		}
		sort.Strings(matches)
		for _, m := range matches {
			add(m)
		}
	}
	return paths, nil
}

// loadReport reads one machine report, rejecting anything that is not a
// well-formed FreshDesk survey report.
func loadReport(path string) (*Report, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("unreadable: %v", err)
	}
	if len(strings.TrimSpace(string(raw))) == 0 {
		return nil, errors.New("empty file")
	}
	var rep Report
	if err := json.Unmarshal(raw, &rep); err != nil {
		return nil, fmt.Errorf("malformed JSON: %v", err)
	}
	if rep.Tool != "" && rep.Tool != toolName {
		return nil, fmt.Errorf("not a freshdesk report (tool=%q)", rep.Tool)
	}
	if rep.Machine == "" {
		return nil, errors.New("missing required field \"machine\"")
	}
	if rep.FormatVersion == 0 {
		return nil, errors.New("missing required field \"format_version\"")
	}
	if rep.FormatVersion > formatVersion {
		return nil, fmt.Errorf("report format_version %d is newer than supported version %d", rep.FormatVersion, formatVersion)
	}
	if len(rep.Categories) == 0 {
		return nil, errors.New("report contains no categories")
	}
	for i, c := range rep.Categories {
		if c.Category == "" {
			return nil, fmt.Errorf("category %d has no name", i+1)
		}
	}
	rep.source = path
	return &rep, nil
}

// loadAll ingests every path, collecting usable reports and the reason each
// unusable one was skipped. One bad file never stops the merge.
func loadAll(paths []string) ([]*Report, []SkippedReport) {
	reports := []*Report{}
	skipped := []SkippedReport{}
	for _, p := range paths {
		rep, err := loadReport(p)
		if err != nil {
			skipped = append(skipped, SkippedReport{Path: p, Reason: err.Error()})
			continue
		}
		reports = append(reports, rep)
	}
	return reports, skipped
}

// ---------------------------------------------------------------------------
// fleet.
// ---------------------------------------------------------------------------

func cmdFleet(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{"dir": true, "threshold": true, "top": true})

	fs := newFlagSet("fleet")
	dir := fs.String("dir", "", "directory of report JSON files")
	threshold := fs.String("threshold", "", "flag machines at or above this size")
	top := fs.Int("top", 10, "how many largest items to list")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		failUsage("fleet: %v", err)
	}
	if *top < 0 {
		failUsage("fleet: --top must not be negative (got %d)", *top)
	}

	var thresholdBytes int64
	thresholdSet := false
	if *threshold != "" {
		v, err := parseSize(*threshold)
		if err != nil {
			failUsage("fleet: --threshold: %v", err)
		}
		thresholdBytes = v
		thresholdSet = true
	}

	paths, err := gatherReportPaths(fs.Args(), *dir)
	if err != nil {
		fail("fleet: %v", err)
	}
	if len(paths) == 0 {
		fail("fleet: no report files given; pass report paths and/or --dir")
	}

	reports, skipped := loadAll(paths)
	if len(reports) == 0 {
		for _, s := range skipped {
			fmt.Fprintf(os.Stderr, "%s: skipped %s: %s\n", toolName, s.Path, s.Reason)
		}
		fail("fleet: no usable reports among %d file(s)", len(paths))
	}

	res := mergeFleet(reports, skipped, len(paths), thresholdBytes, thresholdSet, *top, time.Now())

	if *asJSON {
		data, err := json.MarshalIndent(res, "", "  ")
		if err != nil {
			fail("fleet: encoding result: %v", err)
		}
		os.Stdout.Write(append(data, '\n'))
	} else {
		printFleet(os.Stdout, res)
	}
	if res.Summary.FlaggedMachines > 0 {
		os.Exit(2)
	}
}

func mergeFleet(reports []*Report, skipped []SkippedReport, given int, thresholdBytes int64, thresholdSet bool, top int, now time.Time) *FleetResult {
	res := &FleetResult{
		Tool:          toolName,
		ToolVersion:   toolVersion,
		FormatVersion: formatVersion,
		MergedAt:      now.UTC().Format(time.RFC3339),
		Machines:      []MachineSummary{},
		Categories:    []CategoryTotal{},
		TopItems:      []TopItem{},
		Flagged:       []string{},
		Skipped:       skipped,
	}

	catBytes := map[string]int64{}
	catFiles := map[string]int{}
	catMachines := map[string]int{}
	var allItems []TopItem

	for _, rep := range reports {
		ms := MachineSummary{
			Machine:    rep.Machine,
			Source:     rep.source,
			SurveyedAt: rep.SurveyedAt,
		}
		for _, c := range rep.Categories {
			ms.Files += c.Files
			ms.Bytes += c.Bytes
			catBytes[c.Category] += c.Bytes
			catFiles[c.Category] += c.Files
			if c.Files > 0 {
				catMachines[c.Category]++
			}
			if c.Bytes > ms.TopBytes || (c.Bytes == ms.TopBytes && ms.TopCategory == "") {
				if c.Files > 0 {
					ms.TopCategory = c.Category
					ms.TopBytes = c.Bytes
				}
			}
			for _, it := range c.Items {
				age := 0
				if mt, err := time.Parse(time.RFC3339, it.MTime); err == nil {
					age = ageDays(mt, now)
				}
				allItems = append(allItems, TopItem{
					Machine:  rep.Machine,
					Category: c.Category,
					Path:     it.Path,
					Bytes:    it.Bytes,
					MTime:    it.MTime,
					AgeDays:  age,
				})
			}
		}
		if ms.TopCategory == "" {
			ms.TopCategory = "-"
		}
		res.Summary.TotalFiles += ms.Files
		res.Summary.TotalBytes += ms.Bytes
		res.Machines = append(res.Machines, ms)
	}

	// Worst first; ties broken by machine name so the ranking is stable.
	sort.SliceStable(res.Machines, func(i, j int) bool {
		if res.Machines[i].Bytes != res.Machines[j].Bytes {
			return res.Machines[i].Bytes > res.Machines[j].Bytes
		}
		return res.Machines[i].Machine < res.Machines[j].Machine
	})

	total := res.Summary.TotalBytes
	for i := range res.Machines {
		if total > 0 {
			res.Machines[i].SharePct = float64(res.Machines[i].Bytes) / float64(total) * 100
		}
		if thresholdSet && res.Machines[i].Bytes >= thresholdBytes {
			res.Machines[i].Flagged = true
			res.Flagged = append(res.Flagged, res.Machines[i].Machine)
		}
	}

	// Per-category breakdown, in catalog order first, then any category from a
	// report this build does not know about.
	emitted := map[string]bool{}
	addCat := func(name string) {
		if emitted[name] {
			return
		}
		if _, ok := catBytes[name]; !ok {
			if _, ok2 := catFiles[name]; !ok2 {
				return
			}
		}
		emitted[name] = true
		ct := CategoryTotal{
			Category: name,
			Machines: catMachines[name],
			Files:    catFiles[name],
			Bytes:    catBytes[name],
		}
		if total > 0 {
			ct.SharePct = float64(ct.Bytes) / float64(total) * 100
		}
		res.Categories = append(res.Categories, ct)
	}
	for _, name := range categoryNames() {
		addCat(name)
	}
	extra := make([]string, 0, len(catBytes))
	for name := range catBytes {
		if !emitted[name] {
			extra = append(extra, name)
		}
	}
	sort.Strings(extra)
	for _, name := range extra {
		addCat(name)
	}

	// Largest single items anywhere in the fleet.
	sort.SliceStable(allItems, func(i, j int) bool {
		if allItems[i].Bytes != allItems[j].Bytes {
			return allItems[i].Bytes > allItems[j].Bytes
		}
		if allItems[i].Machine != allItems[j].Machine {
			return allItems[i].Machine < allItems[j].Machine
		}
		return allItems[i].Path < allItems[j].Path
	})
	if top > len(allItems) {
		top = len(allItems)
	}
	res.TopItems = append(res.TopItems, allItems[:top]...)

	res.Summary.ReportsGiven = given
	res.Summary.MachinesMerged = len(res.Machines)
	res.Summary.ReportsSkipped = len(skipped)
	res.Summary.ThresholdBytes = thresholdBytes
	res.Summary.ThresholdSet = thresholdSet
	res.Summary.FlaggedMachines = len(res.Flagged)
	if n := len(res.Machines); n > 0 {
		res.Summary.MeanBytes = total / int64(n)
		sorted := make([]int64, n)
		for i, m := range res.Machines {
			sorted[i] = m.Bytes
		}
		sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
		if n%2 == 1 {
			res.Summary.MedianBytes = sorted[n/2]
		} else {
			res.Summary.MedianBytes = (sorted[n/2-1] + sorted[n/2]) / 2
		}
		res.Summary.WorstMachine = res.Machines[0].Machine
		res.Summary.WorstBytes = res.Machines[0].Bytes
	}
	return res
}

func printFleet(w io.Writer, res *FleetResult) {
	s := res.Summary
	fmt.Fprintf(w, "FreshDesk fleet reclamation rollup\n")
	fmt.Fprintf(w, "Merged at:  %s\n", res.MergedAt)
	fmt.Fprintf(w, "Reports:    %d given, %d merged, %d skipped\n", s.ReportsGiven, s.MachinesMerged, s.ReportsSkipped)
	if s.ThresholdSet {
		fmt.Fprintf(w, "Threshold:  %d bytes (%s)\n", s.ThresholdBytes, humanBytes(s.ThresholdBytes))
	} else {
		fmt.Fprintf(w, "Threshold:  not set\n")
	}
	fmt.Fprintln(w)

	fmt.Fprintf(w, "PER-MACHINE (worst first)\n")
	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "  MACHINE\tFILES\tBYTES\tSIZE\tSHARE\tTOP CATEGORY\tFLAG")
	for _, m := range res.Machines {
		flag := ""
		if m.Flagged {
			flag = "OVER"
		}
		fmt.Fprintf(tw, "  %s\t%d\t%d\t%s\t%.1f%%\t%s\t%s\n",
			m.Machine, m.Files, m.Bytes, humanBytes(m.Bytes), m.SharePct, m.TopCategory, flag)
	}
	tw.Flush()

	fmt.Fprintf(w, "\nPER-CATEGORY (whole fleet)\n")
	cw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(cw, "  CATEGORY\tMACHINES\tFILES\tBYTES\tSIZE\tSHARE")
	for _, c := range res.Categories {
		fmt.Fprintf(cw, "  %s\t%d\t%d\t%d\t%s\t%.1f%%\n",
			c.Category, c.Machines, c.Files, c.Bytes, humanBytes(c.Bytes), c.SharePct)
	}
	fmt.Fprintf(cw, "  TOTAL\t%d\t%d\t%d\t%s\t\n", s.MachinesMerged, s.TotalFiles, s.TotalBytes, humanBytes(s.TotalBytes))
	cw.Flush()

	if len(res.TopItems) > 0 {
		fmt.Fprintf(w, "\nLARGEST SINGLE ITEMS IN THE FLEET (top %d)\n", len(res.TopItems))
		iw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
		fmt.Fprintln(iw, "  #\tBYTES\tSIZE\tAGE(d)\tMACHINE\tCATEGORY\tPATH")
		for i, it := range res.TopItems {
			fmt.Fprintf(iw, "  %d\t%d\t%s\t%d\t%s\t%s\t%s\n",
				i+1, it.Bytes, humanBytes(it.Bytes), it.AgeDays, it.Machine, it.Category, it.Path)
		}
		iw.Flush()
	}

	if len(res.Skipped) > 0 {
		fmt.Fprintf(w, "\nSKIPPED REPORTS\n")
		sw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
		for _, sk := range res.Skipped {
			fmt.Fprintf(sw, "  %s\t%s\n", sk.Path, sk.Reason)
		}
		sw.Flush()
	}

	fmt.Fprintf(w, "\nFLEET SUMMARY\n")
	fmt.Fprintf(w, "  Machines merged:         %d\n", s.MachinesMerged)
	fmt.Fprintf(w, "  Reports skipped:         %d\n", s.ReportsSkipped)
	fmt.Fprintf(w, "  Total reclaimable files: %d\n", s.TotalFiles)
	fmt.Fprintf(w, "  Total reclaimable bytes: %d (%s)\n", s.TotalBytes, humanBytes(s.TotalBytes))
	fmt.Fprintf(w, "  Mean per machine:        %d (%s)\n", s.MeanBytes, humanBytes(s.MeanBytes))
	fmt.Fprintf(w, "  Median per machine:      %d (%s)\n", s.MedianBytes, humanBytes(s.MedianBytes))
	if s.WorstMachine != "" {
		fmt.Fprintf(w, "  Worst machine:           %s (%s)\n", s.WorstMachine, humanBytes(s.WorstBytes))
	}
	if s.ThresholdSet {
		if s.FlaggedMachines > 0 {
			fmt.Fprintf(w, "  Over threshold:          %d machine(s): %s\n", s.FlaggedMachines, strings.Join(res.Flagged, ", "))
		} else {
			fmt.Fprintf(w, "  Over threshold:          none\n")
		}
	}
	fmt.Fprintf(w, "\n%s\n", remediationNote)
}

// ---------------------------------------------------------------------------
// plan.
// ---------------------------------------------------------------------------

func cmdPlan(args []string) {
	if containsHelp(args) {
		usage(os.Stdout)
		os.Exit(0)
	}
	args = reorderFlags(args, map[string]bool{"dir": true, "category": true, "min-age": true})

	fs := newFlagSet("plan")
	dir := fs.String("dir", "", "directory of report JSON files")
	category := fs.String("category", "", "category to plan a cleanup for")
	minAge := fs.String("min-age", "0d", "only count items at least this old")
	asJSON := fs.Bool("json", false, "emit JSON")
	if err := fs.Parse(args); err != nil {
		failUsage("plan: %v", err)
	}
	if *category == "" {
		failUsage("plan: --category is required (known: %s)", strings.Join(categoryNames(), ", "))
	}
	entry, ok := lookupCategory(*category)
	if !ok {
		fail("plan: unknown category %q (known: %s)", *category, strings.Join(categoryNames(), ", "))
	}
	age, err := parseAge(*minAge)
	if err != nil {
		failUsage("plan: --min-age: %v", err)
	}
	if *dir == "" && len(fs.Args()) == 0 {
		failUsage("plan: --dir is required (or pass report paths)")
	}

	paths, err := gatherReportPaths(fs.Args(), *dir)
	if err != nil {
		fail("plan: %v", err)
	}
	if len(paths) == 0 {
		fail("plan: no report files found; pass report paths and/or --dir")
	}

	reports, skipped := loadAll(paths)
	if len(reports) == 0 {
		for _, s := range skipped {
			fmt.Fprintf(os.Stderr, "%s: skipped %s: %s\n", toolName, s.Path, s.Reason)
		}
		fail("plan: no usable reports among %d file(s)", len(paths))
	}

	res := buildPlan(reports, skipped, entry, *minAge, age, time.Now())

	if *asJSON {
		data, err := json.MarshalIndent(res, "", "  ")
		if err != nil {
			fail("plan: encoding result: %v", err)
		}
		os.Stdout.Write(append(data, '\n'))
		return
	}
	printPlan(os.Stdout, res)
}

// buildPlan totals what a fleet-wide cleanup of one category would recover.
// An item counts only if it is at least minAge old, measured from now against
// the modification time recorded in the survey.
func buildPlan(reports []*Report, skipped []SkippedReport, entry catalogEntry, minAgeText string, minAge time.Duration, now time.Time) *PlanResult {
	res := &PlanResult{
		Tool:          toolName,
		ToolVersion:   toolVersion,
		FormatVersion: formatVersion,
		PlannedAt:     now.UTC().Format(time.RFC3339),
		Category:      entry.category,
		Description:   entry.desc,
		MinAge:        minAgeText,
		MinAgeDays:    minAge.Hours() / 24,
		Machines:      []PlanMachine{},
		Skipped:       skipped,
		Note:          remediationNote,
	}
	cutoff := now.Add(-minAge)

	for _, rep := range reports {
		pm := PlanMachine{Machine: rep.Machine, Source: rep.source}
		var oldest time.Time
		for _, c := range rep.Categories {
			if c.Category != entry.category {
				continue
			}
			for _, it := range c.Items {
				mt, err := time.Parse(time.RFC3339, it.MTime)
				if err != nil {
					pm.SkippedFiles++
					pm.SkippedBytes += it.Bytes
					continue
				}
				if mt.After(cutoff) {
					pm.SkippedFiles++
					pm.SkippedBytes += it.Bytes
					continue
				}
				pm.Files++
				pm.Bytes += it.Bytes
				if oldest.IsZero() || mt.Before(oldest) {
					oldest = mt
					pm.OldestPath = it.Path
				}
			}
		}
		if !oldest.IsZero() {
			pm.OldestAgeDays = ageDays(oldest, now)
		}
		res.Machines = append(res.Machines, pm)
		res.Summary.TotalFiles += pm.Files
		res.Summary.TotalBytes += pm.Bytes
		res.Summary.HeldBackFiles += pm.SkippedFiles
		res.Summary.HeldBackBytes += pm.SkippedBytes
		if pm.Files > 0 {
			res.Summary.MachinesWithAny++
		}
	}

	sort.SliceStable(res.Machines, func(i, j int) bool {
		if res.Machines[i].Bytes != res.Machines[j].Bytes {
			return res.Machines[i].Bytes > res.Machines[j].Bytes
		}
		return res.Machines[i].Machine < res.Machines[j].Machine
	})
	res.Summary.MachinesMerged = len(res.Machines)
	res.Summary.ReportsSkipped = len(skipped)
	return res
}

func printPlan(w io.Writer, res *PlanResult) {
	fmt.Fprintf(w, "FreshDesk cleanup plan (proposal only - nothing is deleted)\n")
	fmt.Fprintf(w, "Category:   %s - %s\n", res.Category, res.Description)
	fmt.Fprintf(w, "Min age:    %s (%.2f days)\n", res.MinAge, res.MinAgeDays)
	fmt.Fprintf(w, "Planned at: %s\n\n", res.PlannedAt)

	tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
	fmt.Fprintln(tw, "  MACHINE\tFILES\tBYTES\tSIZE\tOLDEST(d)\tHELD BACK")
	for _, m := range res.Machines {
		oldest := "-"
		if m.Files > 0 {
			oldest = strconv.Itoa(m.OldestAgeDays)
		}
		fmt.Fprintf(tw, "  %s\t%d\t%d\t%s\t%s\t%d file(s) / %s\n",
			m.Machine, m.Files, m.Bytes, humanBytes(m.Bytes), oldest, m.SkippedFiles, humanBytes(m.SkippedBytes))
	}
	s := res.Summary
	fmt.Fprintf(tw, "  TOTAL\t%d\t%d\t%s\t\t%d file(s) / %s\n",
		s.TotalFiles, s.TotalBytes, humanBytes(s.TotalBytes), s.HeldBackFiles, humanBytes(s.HeldBackBytes))
	tw.Flush()

	if len(res.Skipped) > 0 {
		fmt.Fprintf(w, "\nSKIPPED REPORTS\n")
		sw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
		for _, sk := range res.Skipped {
			fmt.Fprintf(sw, "  %s\t%s\n", sk.Path, sk.Reason)
		}
		sw.Flush()
	}

	fmt.Fprintf(w, "\nPLAN SUMMARY\n")
	fmt.Fprintf(w, "  Machines in plan:        %d (%d with something to recover)\n", s.MachinesMerged, s.MachinesWithAny)
	fmt.Fprintf(w, "  Reports skipped:         %d\n", s.ReportsSkipped)
	fmt.Fprintf(w, "  Recoverable files:       %d\n", s.TotalFiles)
	fmt.Fprintf(w, "  Recoverable bytes:       %d (%s)\n", s.TotalBytes, humanBytes(s.TotalBytes))
	fmt.Fprintf(w, "  Held back (too new):     %d file(s), %d bytes (%s)\n", s.HeldBackFiles, s.HeldBackBytes, humanBytes(s.HeldBackBytes))
	fmt.Fprintf(w, "\n%s\n", res.Note)
}
