// Command restoreguard is a hardlink-deduplicated snapshot backup tool with
// tiered ("grandfather-father-son" / GFS) retention pruning.
//
// It shares its backup mechanism (hardlink dedup + sidecar JSON manifest)
// with the sibling tool RescueVault, but replaces RescueVault's flat
// "keep last N" pruning with a real tiered retention policy:
//
//	< 24h old:        keep EVERY snapshot            (hourly tier)
//	24h  - 7d old:    keep one snapshot per day       (daily tier)
//	7d   - 28d old:   keep one snapshot per week      (weekly tier)
//	>= 28d old:        remove                          (no long-term tier)
//
// See README.txt for full documentation.
package main

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"flag"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"
)

const (
	manifestName = ".restoreguard-manifest.json"
	snapshotsDir = "snapshots"

	// Tier boundaries. Ages are computed as (now - snapshot.CreatedAt).
	hourlyTierMax = 24 * time.Hour      // < 24h: keep everything
	dailyTierMax  = 7 * 24 * time.Hour  // 24h..7d: one per calendar day
	weeklyTierMax = 28 * 24 * time.Hour // 7d..28d: one per calendar week
)

// ---------------------------------------------------------------------------
// Manifest types
// ---------------------------------------------------------------------------

// ManifestEntry records how a single file ended up in a snapshot: either a
// fresh copy of its bytes, or a hardlink to the identical file in the prior
// snapshot. We record this explicitly because there is no portable, reliable
// way to rediscover "is this a hardlink and to what" purely from
// os.FileInfo/os.Stat across Windows/macOS/Linux after the fact.
type ManifestEntry struct {
	Path    string    `json:"path"`
	Size    int64     `json:"size"`
	ModTime time.Time `json:"mod_time"`
	SHA256  string    `json:"sha256"`
	Action  string    `json:"action"` // "copied" or "hardlinked"
}

// Manifest is the per-snapshot sidecar file: <vault>/snapshots/<id>/.restoreguard-manifest.json
type Manifest struct {
	SnapshotID string          `json:"snapshot_id"`
	CreatedAt  time.Time       `json:"created_at"`
	Source     string          `json:"source"`
	Files      []ManifestEntry `json:"files"`
}

// Snapshot is a loaded, summarized view of one snapshot directory, used by
// prune/list.
type Snapshot struct {
	ID        string
	Dir       string
	CreatedAt time.Time
	FileCount int
	Manifest  *Manifest
}

// ---------------------------------------------------------------------------
// main / dispatch
// ---------------------------------------------------------------------------

func main() {
	if len(os.Args) < 2 {
		// 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.Exit(1)
	}

	switch os.Args[1] {
	case "-h", "--help", "help":
		usage()
		return
	case "backup":
		cmdBackup(os.Args[2:])
	case "prune":
		cmdPrune(os.Args[2:])
	case "list":
		cmdList(os.Args[2:])
	default:
		fmt.Fprintf(os.Stderr, "restoreguard: unknown command %q\n\n", os.Args[1])
		usage()
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `restoreguard - hardlink-deduplicated backups with tiered (GFS) retention

USAGE:
  restoreguard backup <src> <vaultdir> [--apply]
      Take a snapshot of <src> into <vaultdir>/snapshots/<id>/.
      Unchanged files are hardlinked to the previous snapshot (no wasted
      disk space); new/changed files are copied. Without --apply this is
      a dry run that only reports what would happen.

  restoreguard prune <vaultdir> [--now <RFC3339>] [--apply]
      Apply the tiered retention policy to existing snapshots:
        < 24h old   : keep ALL          (hourly tier)
        24h .. 7d   : keep 1 per day    (daily tier)
        7d .. 28d   : keep 1 per week   (weekly tier)
        >= 28d      : remove
      --now pins "now" for the age calculation (also useful for auditing
      a retention policy at a specific point in time, not just testing).
      Without --apply this is a dry run that only reports the plan.

  restoreguard list <vaultdir> [--now <RFC3339>] [--json]
      List all snapshots with id, age, file count, and which retention
      tier currently protects them (or "REMOVE" if next prune would drop
      them).

  restoreguard help
      Show this message.
`)
}

// ---------------------------------------------------------------------------
// flag reordering workaround
// ---------------------------------------------------------------------------

// reorderFlags moves all recognized flags (and their values, for flags in
// valueFlags) to the front of args and positional arguments to the back, so
// that Go's flag package -- which stops parsing at the first non-flag
// argument -- still sees every flag regardless of where the user put it.
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...)
}

// ---------------------------------------------------------------------------
// helpers
// ---------------------------------------------------------------------------

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

func humanDuration(d time.Duration) string {
	if d < 0 {
		d = -d
	}
	switch {
	case d < time.Minute:
		return fmt.Sprintf("%ds", int(d.Seconds()))
	case d < time.Hour:
		return fmt.Sprintf("%dm", int(d.Minutes()))
	case d < 24*time.Hour:
		return fmt.Sprintf("%.1fh", d.Hours())
	default:
		return fmt.Sprintf("%.1fd", d.Hours()/24)
	}
}

func sha256File(path string) (string, error) {
	f, err := os.Open(path)
	if err != nil {
		return "", err
	}
	defer f.Close()
	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// newSnapshotID returns a lexically-sortable, filesystem-safe, unique
// (down to the nanosecond) snapshot directory name.
func newSnapshotID(t time.Time) string {
	return t.UTC().Format("20060102-150405.000000000")
}

// parseNow parses the --now flag (RFC3339), or returns the real current
// time (UTC) if now is empty.
func parseNow(now string) (time.Time, error) {
	if now == "" {
		return time.Now().UTC(), nil
	}
	t, err := time.Parse(time.RFC3339, now)
	if err != nil {
		return time.Time{}, fmt.Errorf("invalid --now value %q (want RFC3339, e.g. 2026-08-10T15:04:05Z): %w", now, err)
	}
	return t.UTC(), nil
}

// ---------------------------------------------------------------------------
// backup
// ---------------------------------------------------------------------------

func cmdBackup(args []string) {
	if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
		fmt.Print(`restoreguard backup <src> <vaultdir> [--apply]

Takes a hardlink-deduplicated snapshot of <src> into
<vaultdir>/snapshots/<timestamp>/. Files whose content is unchanged from
the most recent prior snapshot are hardlinked (os.Link) instead of
copied, so unchanged data costs zero additional disk space. Changed or
new files are copied in full. A sidecar manifest
(.restoreguard-manifest.json) records, per file, whether it was copied
or hardlinked, and its sha256, since there is no portable way to detect
"this file is a hardlink" after the fact via os.FileInfo alone on all of
Windows/macOS/Linux.

Without --apply: dry run, reports new/changed/unchanged counts only.
With --apply: performs the backup for real.
`)
		return
	}

	fs2 := flag.NewFlagSet("backup", flag.ExitOnError)
	apply := fs2.Bool("apply", false, "perform the backup for real (default: dry run)")
	reordered := reorderFlags(args, map[string]bool{"apply": false})
	fs2.Parse(reordered)

	pos := fs2.Args()
	if len(pos) < 2 {
		fmt.Fprintln(os.Stderr, "restoreguard backup: need <src> and <vaultdir>")
		fmt.Fprintln(os.Stderr, "usage: restoreguard backup <src> <vaultdir> [--apply]")
		os.Exit(1)
	}
	src := pos[0]
	vault := pos[1]

	srcInfo, err := os.Stat(src)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}
	if !srcInfo.IsDir() {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %s is not a directory\n", src)
		os.Exit(1)
	}

	snapDir := filepath.Join(vault, snapshotsDir)
	if err := os.MkdirAll(snapDir, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}

	prior, err := latestSnapshot(snapDir)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}

	// Build a lookup of prior snapshot's files by relative path.
	priorFiles := map[string]ManifestEntry{}
	if prior != nil {
		for _, e := range prior.Manifest.Files {
			priorFiles[e.Path] = e
		}
	}

	type plannedFile struct {
		relPath string
		absPath string
		size    int64
		modTime time.Time
		sha     string
		action  string // "new", "changed", "unchanged"
	}
	var planned []plannedFile

	err = filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		info, err := d.Info()
		if err != nil {
			return err
		}
		sum, err := sha256File(path)
		if err != nil {
			return err
		}
		action := "new"
		if prev, ok := priorFiles[rel]; ok {
			if prev.SHA256 == sum {
				action = "unchanged"
			} else {
				action = "changed"
			}
		}
		planned = append(planned, plannedFile{
			relPath: rel, absPath: path, size: info.Size(),
			modTime: info.ModTime(), sha: sum, action: action,
		})
		return nil
	})
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}

	sort.Slice(planned, func(i, j int) bool { return planned[i].relPath < planned[j].relPath })

	var newCount, changedCount, unchangedCount int
	var newBytes, changedBytes int64
	for _, p := range planned {
		switch p.action {
		case "new":
			newCount++
			newBytes += p.size
		case "changed":
			changedCount++
			changedBytes += p.size
		case "unchanged":
			unchangedCount++
		}
	}

	now := time.Now().UTC()
	id := newSnapshotID(now)

	if !*apply {
		fmt.Printf("DRY RUN (no changes made) - would create snapshot %s\n", id)
		fmt.Printf("  source:     %s\n", src)
		fmt.Printf("  vault:      %s\n", vault)
		if prior != nil {
			fmt.Printf("  prior snap: %s\n", prior.ID)
		} else {
			fmt.Printf("  prior snap: (none - this would be the first snapshot)\n")
		}
		fmt.Println()
		for _, p := range planned {
			fmt.Printf("  %-10s %s (%s)\n", strings.ToUpper(p.action), p.relPath, humanBytes(p.size))
		}
		fmt.Println()
		fmt.Printf("  new: %d (%s)   changed: %d (%s)   unchanged (would hardlink): %d\n",
			newCount, humanBytes(newBytes), changedCount, humanBytes(changedBytes), unchangedCount)
		fmt.Println("\nRun again with --apply to perform this backup.")
		return
	}

	// Real run.
	thisDir := filepath.Join(snapDir, id)
	if err := os.MkdirAll(thisDir, 0o755); err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}

	m := &Manifest{SnapshotID: id, CreatedAt: now, Source: src}

	for _, p := range planned {
		dstPath := filepath.Join(thisDir, filepath.FromSlash(p.relPath))
		if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
			fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
			os.Exit(1)
		}

		action := "copied"
		if p.action == "unchanged" {
			// Hardlink to the file in the prior snapshot instead of copying.
			priorPath := filepath.Join(prior.Dir, filepath.FromSlash(p.relPath))
			if err := os.Link(priorPath, dstPath); err != nil {
				// Fall back to copying if hardlinking fails for any reason
				// (e.g. cross-device vault, though same-vault should never
				// hit this).
				if err2 := copyFile(p.absPath, dstPath); err2 != nil {
					fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err2)
					os.Exit(1)
				}
			} else {
				action = "hardlinked"
			}
		} else {
			if err := copyFile(p.absPath, dstPath); err != nil {
				fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
				os.Exit(1)
			}
		}

		m.Files = append(m.Files, ManifestEntry{
			Path: p.relPath, Size: p.size, ModTime: p.modTime, SHA256: p.sha, Action: action,
		})
	}

	if err := writeManifest(thisDir, m); err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard backup: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Snapshot %s created in %s\n", id, thisDir)
	fmt.Printf("  new: %d (%s)   changed: %d (%s)   hardlinked (unchanged): %d\n",
		newCount, humanBytes(newBytes), changedCount, humanBytes(changedBytes), unchangedCount)
}

func copyFile(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()
	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	if _, err := io.Copy(out, in); err != nil {
		out.Close()
		return err
	}
	if err := out.Close(); err != nil {
		return err
	}
	info, err := in.Stat()
	if err == nil {
		_ = os.Chtimes(dst, info.ModTime(), info.ModTime())
	}
	return nil
}

func writeManifest(snapDir string, m *Manifest) error {
	b, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(snapDir, manifestName), b, 0o644)
}

// ---------------------------------------------------------------------------
// snapshot loading (shared by backup/prune/list)
// ---------------------------------------------------------------------------

func loadSnapshots(vaultDir string) ([]*Snapshot, error) {
	snapDir := filepath.Join(vaultDir, snapshotsDir)
	entries, err := os.ReadDir(snapDir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var out []*Snapshot
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		dir := filepath.Join(snapDir, e.Name())
		mPath := filepath.Join(dir, manifestName)
		b, err := os.ReadFile(mPath)
		if err != nil {
			// Not a valid snapshot directory (missing manifest) - skip.
			continue
		}
		var m Manifest
		if err := json.Unmarshal(b, &m); err != nil {
			return nil, fmt.Errorf("parsing manifest %s: %w", mPath, err)
		}
		out = append(out, &Snapshot{
			ID:        e.Name(),
			Dir:       dir,
			CreatedAt: m.CreatedAt.UTC(),
			FileCount: len(m.Files),
			Manifest:  &m,
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) })
	return out, nil
}

func latestSnapshot(snapDir string) (*Snapshot, error) {
	vaultDir := filepath.Dir(snapDir)
	snaps, err := loadSnapshots(vaultDir)
	if err != nil {
		return nil, err
	}
	if len(snaps) == 0 {
		return nil, nil
	}
	return snaps[len(snaps)-1], nil
}

// ---------------------------------------------------------------------------
// retention classification (the tiered GFS logic)
// ---------------------------------------------------------------------------

type Classification struct {
	Snap   *Snapshot
	Age    time.Duration
	Tier   string // "hourly", "daily", "weekly", "remove"
	Keep   bool
	Reason string
}

// classifyRetention implements the tiered retention policy:
//
//	age < 24h            -> hourly tier, always kept
//	24h <= age < 7d       -> daily tier, kept only if newest snapshot that UTC calendar day
//	7d  <= age < 28d      -> weekly tier, kept only if newest snapshot that ISO calendar week
//	age >= 28d            -> removed (no longer-term tier in this prototype)
//
// snaps must be sorted oldest-first (as loadSnapshots returns them).
func classifyRetention(snaps []*Snapshot, now time.Time) []Classification {
	out := make([]Classification, len(snaps))
	for i, s := range snaps {
		age := now.Sub(s.CreatedAt)
		out[i] = Classification{Snap: s, Age: age}
		switch {
		case age < hourlyTierMax:
			out[i].Tier = "hourly"
			out[i].Keep = true
			out[i].Reason = "less than 24h old - hourly tier keeps everything"
		case age < dailyTierMax:
			out[i].Tier = "daily"
			// resolved below: newest per calendar day is kept
		case age < weeklyTierMax:
			out[i].Tier = "weekly"
			// resolved below: newest per calendar week is kept
		default:
			out[i].Tier = "remove"
			out[i].Keep = false
			out[i].Reason = "28 days or older - beyond retention window"
		}
	}

	// Daily tier: group by UTC calendar day (snapshot's own date), keep newest per group.
	dailyGroups := map[string]int{} // day -> index of newest-so-far in out
	for i, c := range out {
		if c.Tier != "daily" {
			continue
		}
		key := c.Snap.CreatedAt.UTC().Format("2006-01-02")
		if cur, ok := dailyGroups[key]; !ok || out[i].Snap.CreatedAt.After(out[cur].Snap.CreatedAt) {
			dailyGroups[key] = i
		}
	}
	for i, c := range out {
		if c.Tier != "daily" {
			continue
		}
		key := c.Snap.CreatedAt.UTC().Format("2006-01-02")
		if dailyGroups[key] == i {
			out[i].Keep = true
			out[i].Reason = fmt.Sprintf("daily tier - newest snapshot on %s", key)
		} else {
			out[i].Keep = false
			out[i].Reason = fmt.Sprintf("daily tier - superseded by a newer snapshot on %s", key)
		}
	}

	// Weekly tier: group by ISO (year, week) of snapshot's own date, keep newest per group.
	weeklyGroups := map[string]int{}
	for i, c := range out {
		if c.Tier != "weekly" {
			continue
		}
		y, w := c.Snap.CreatedAt.UTC().ISOWeek()
		key := fmt.Sprintf("%d-W%02d", y, w)
		if cur, ok := weeklyGroups[key]; !ok || out[i].Snap.CreatedAt.After(out[cur].Snap.CreatedAt) {
			weeklyGroups[key] = i
		}
	}
	for i, c := range out {
		if c.Tier != "weekly" {
			continue
		}
		y, w := c.Snap.CreatedAt.UTC().ISOWeek()
		key := fmt.Sprintf("%d-W%02d", y, w)
		if weeklyGroups[key] == i {
			out[i].Keep = true
			out[i].Reason = fmt.Sprintf("weekly tier - newest snapshot in ISO week %s", key)
		} else {
			out[i].Keep = false
			out[i].Reason = fmt.Sprintf("weekly tier - superseded by a newer snapshot in ISO week %s", key)
		}
	}

	return out
}

// ---------------------------------------------------------------------------
// prune
// ---------------------------------------------------------------------------

func cmdPrune(args []string) {
	if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
		fmt.Print(`restoreguard prune <vaultdir> [--now <RFC3339>] [--apply]

Applies the tiered ("grandfather-father-son") retention policy to the
snapshots in <vaultdir>/snapshots/:

  < 24h old    keep ALL snapshots               (hourly tier)
  24h..7d old  keep the newest per calendar day (daily tier)
  7d..28d old  keep the newest per calendar week(weekly tier)
  >= 28d old   remove (no longer-term tier in this prototype)

--now <RFC3339> pins what "now" means for age calculations. It defaults
to the real current time. This is a genuine feature, not just a testing
hack: it lets you audit what a retention policy would decide at any
past or future point in time.

Without --apply: dry run, prints the plan only, removes nothing.
With --apply: actually removes the snapshots classified REMOVE.

Correctness note: removing an older snapshot directory never destroys
data still needed by a surviving, newer snapshot. Hardlinked files are
independent directory entries pointing at the same inode; the OS only
frees the underlying data once its link count drops to zero. Since we
only ever remove whole snapshot directories (never edit files within a
surviving snapshot), every surviving snapshot's hardlinks keep working
after an older snapshot they may once have pointed back to is deleted.
`)
		return
	}

	fs2 := flag.NewFlagSet("prune", flag.ExitOnError)
	apply := fs2.Bool("apply", false, "actually remove snapshots (default: dry run)")
	nowFlag := fs2.String("now", "", "pin 'now' for age calculations (RFC3339, default: real current time)")
	reordered := reorderFlags(args, map[string]bool{"apply": false, "now": true})
	fs2.Parse(reordered)

	pos := fs2.Args()
	if len(pos) < 1 {
		fmt.Fprintln(os.Stderr, "restoreguard prune: need <vaultdir>")
		fmt.Fprintln(os.Stderr, "usage: restoreguard prune <vaultdir> [--now <RFC3339>] [--apply]")
		os.Exit(1)
	}
	vault := pos[0]

	now, err := parseNow(*nowFlag)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard prune: %v\n", err)
		os.Exit(1)
	}

	snaps, err := loadSnapshots(vault)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard prune: %v\n", err)
		os.Exit(1)
	}

	if len(snaps) == 0 {
		fmt.Printf("No snapshots found under %s - nothing to prune.\n", filepath.Join(vault, snapshotsDir))
		return
	}

	classes := classifyRetention(snaps, now)

	fmt.Printf("Retention plan for %s (now = %s):\n\n", vault, now.Format(time.RFC3339))
	fmt.Printf("  %-32s %-10s %-8s %-6s %s\n", "SNAPSHOT", "AGE", "TIER", "FILES", "DECISION / REASON")
	var toRemove []*Classification
	var keepCount, removeCount int
	for i := range classes {
		c := &classes[i]
		decision := "KEEP"
		if !c.Keep {
			decision = "REMOVE"
			toRemove = append(toRemove, c)
			removeCount++
		} else {
			keepCount++
		}
		fmt.Printf("  %-32s %-10s %-8s %-6d %s: %s\n",
			c.Snap.ID, humanDuration(c.Age), c.Tier, c.Snap.FileCount, decision, c.Reason)
	}
	fmt.Printf("\n%d to keep, %d to remove.\n", keepCount, removeCount)

	if !*apply {
		fmt.Println("\nDRY RUN - nothing was removed. Run again with --apply to prune for real.")
		return
	}

	if len(toRemove) == 0 {
		fmt.Println("\nNothing to remove.")
		return
	}

	fmt.Println()
	for _, c := range toRemove {
		if err := os.RemoveAll(c.Snap.Dir); err != nil {
			fmt.Fprintf(os.Stderr, "restoreguard prune: failed to remove %s: %v\n", c.Snap.Dir, err)
			os.Exit(1)
		}
		fmt.Printf("Removed %s\n", c.Snap.ID)
	}
	fmt.Printf("\nPruned %d snapshot(s).\n", len(toRemove))
}

// ---------------------------------------------------------------------------
// list
// ---------------------------------------------------------------------------

type listRow struct {
	ID        string    `json:"id"`
	CreatedAt time.Time `json:"created_at"`
	AgeSecs   float64   `json:"age_seconds"`
	Age       string    `json:"age"`
	FileCount int       `json:"file_count"`
	Tier      string    `json:"tier"`
	Decision  string    `json:"decision"`
	Reason    string    `json:"reason"`
}

func cmdList(args []string) {
	if len(args) > 0 && (args[0] == "-h" || args[0] == "--help" || args[0] == "help") {
		fmt.Print(`restoreguard list <vaultdir> [--now <RFC3339>] [--json]

Lists all snapshots in <vaultdir>/snapshots/ with their id, age
(relative to --now, or real current time if omitted), file count, and
which retention tier currently protects them, or REMOVE if the next
'restoreguard prune' would drop them. Uses the exact same classification
logic as 'prune', so 'list --now X' and 'prune --now X' always agree.
`)
		return
	}

	fs2 := flag.NewFlagSet("list", flag.ExitOnError)
	jsonOut := fs2.Bool("json", false, "output JSON instead of a table")
	nowFlag := fs2.String("now", "", "pin 'now' for age calculations (RFC3339, default: real current time)")
	reordered := reorderFlags(args, map[string]bool{"json": false, "now": true})
	fs2.Parse(reordered)

	pos := fs2.Args()
	if len(pos) < 1 {
		fmt.Fprintln(os.Stderr, "restoreguard list: need <vaultdir>")
		fmt.Fprintln(os.Stderr, "usage: restoreguard list <vaultdir> [--now <RFC3339>] [--json]")
		os.Exit(1)
	}
	vault := pos[0]

	now, err := parseNow(*nowFlag)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard list: %v\n", err)
		os.Exit(1)
	}

	snaps, err := loadSnapshots(vault)
	if err != nil {
		fmt.Fprintf(os.Stderr, "restoreguard list: %v\n", err)
		os.Exit(1)
	}

	classes := classifyRetention(snaps, now)

	rows := []listRow{}
	for _, c := range classes {
		decision := "KEEP"
		if !c.Keep {
			decision = "REMOVE (next prune)"
		}
		rows = append(rows, listRow{
			ID: c.Snap.ID, CreatedAt: c.Snap.CreatedAt, AgeSecs: c.Age.Seconds(),
			Age: humanDuration(c.Age), FileCount: c.Snap.FileCount,
			Tier: c.Tier, Decision: decision, Reason: c.Reason,
		})
	}

	if *jsonOut {
		b, err := json.MarshalIndent(rows, "", "  ")
		if err != nil {
			fmt.Fprintf(os.Stderr, "restoreguard list: %v\n", err)
			os.Exit(1)
		}
		fmt.Println(string(b))
		return
	}

	if len(rows) == 0 {
		fmt.Printf("No snapshots found under %s\n", filepath.Join(vault, snapshotsDir))
		return
	}

	fmt.Printf("Snapshots in %s (now = %s):\n\n", vault, now.Format(time.RFC3339))
	fmt.Printf("  %-32s %-20s %-10s %-6s %-8s %s\n", "SNAPSHOT", "CREATED", "AGE", "FILES", "TIER", "STATUS")
	for _, r := range rows {
		fmt.Printf("  %-32s %-20s %-10s %-6d %-8s %s\n",
			r.ID, r.CreatedAt.Format("2006-01-02 15:04:05"), r.Age, r.FileCount, r.Tier, r.Decision)
	}
}
