// Command diskwatch records timestamped disk-usage samples and forecasts
// capacity exhaustion with a least-squares linear regression.
package main

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

const appName = "diskwatch"

// ---------------------------------------------------------------------------
// Shared Techlosoft UX helpers (verbatim across the tool line)
// ---------------------------------------------------------------------------

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

// ---------------------------------------------------------------------------
// History records
// ---------------------------------------------------------------------------

// Entry is the measurement of one watched tree at one point in time.
type Entry struct {
	Path   string `json:"path"`
	Bytes  int64  `json:"bytes"`
	Files  int64  `json:"files"`
	Dirs   int64  `json:"dirs"`
	Errors int64  `json:"errors"`
}

// Record is one JSON line of the append-only history file.
type Record struct {
	TS       time.Time `json:"ts"`
	Capacity int64     `json:"capacity,omitempty"`
	Entries  []Entry   `json:"entries"`
}

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

func usage() {
	fmt.Fprintf(os.Stderr, `%s - capacity forecasting for directory trees (Techlosoft Storage Health Center)

USAGE
  %s sample   --watch <dir> [--watch <dir> ...] --history <file.jsonl> [--capacity 10GB]
  %s forecast --history <file.jsonl> [--capacity 10GB] [--json]
  %s history  --history <file.jsonl> [--json]
  %s help | -h | --help

COMMANDS
  sample     Measure recursive byte usage of each watched tree and append ONE
             JSON line to the history file. The history is append-only; earlier
             lines are never rewritten.
  forecast   Fit a least-squares linear regression of bytes over time for each
             watched path and project days until the stated capacity is reached.
  history    List the samples recorded so far.

FLAGS
  --watch <dir>     Directory tree to measure. Repeat for multiple trees.
  --history <file>  Path to the JSON-lines history file (created on first sample).
  --capacity <size> Capacity to forecast against: 10GB, 500MB, 2TiB or raw bytes.
                    Suffixes are binary (1KB = 1024 B). If omitted on forecast,
                    the most recent capacity recorded in the history is used.
  --json            Machine-readable JSON output (forecast, history).

EXAMPLES
  %s sample --watch /var/log --watch ./data --history usage.jsonl --capacity 20GB
  %s forecast --history usage.jsonl
  %s forecast --history usage.jsonl --capacity 50GB --json
  %s history --history usage.jsonl

Flags may appear before or after positional arguments.
`, appName, appName, appName, appName, appName, appName, appName, appName, appName)
}

func fail(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n", appName, fmt.Sprintf(format, args...))
	os.Exit(1)
}

func usageErr(format string, args ...any) {
	fmt.Fprintf(os.Stderr, "%s: %s\n\n", appName, fmt.Sprintf(format, args...))
	usage()
	os.Exit(1)
}

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

func main() {
	args := os.Args[1:]
	if len(args) == 0 {
		// Double-clicked in Explorer rather than run from a prompt: ask the
		// one question the program needs and stay on screen. Printing usage
		// and exiting here is what made the window vanish instantly.
		if interactiveConsole() {
			runGuided()
			return
		}
		usage()
		os.Exit(1)
	}
	switch args[0] {
	case "help", "-h", "--help":
		usage()
		os.Exit(0)
	}
	cmd := args[0]
	rest := args[1:]
	for _, a := range rest {
		if a == "-h" || a == "--help" || a == "help" {
			usage()
			os.Exit(0)
		}
	}
	switch cmd {
	case "sample":
		cmdSample(rest)
	case "forecast":
		cmdForecast(rest)
	case "history":
		cmdHistory(rest)
	default:
		usageErr("unknown command %q", cmd)
	}
}

// ---------------------------------------------------------------------------
// Flag plumbing
// ---------------------------------------------------------------------------

type stringList []string

func (s *stringList) String() string { return strings.Join(*s, ",") }

func (s *stringList) Set(v string) error {
	if strings.TrimSpace(v) == "" {
		return errors.New("empty value")
	}
	*s = append(*s, v)
	return nil
}

var valueFlags = map[string]bool{
	"watch": true, "w": true,
	"history": true, "f": true,
	"capacity": true, "c": true,
}

func newFlagSet(name string) *flag.FlagSet {
	fs := flag.NewFlagSet(name, flag.ContinueOnError)
	fs.SetOutput(os.Stderr)
	fs.Usage = usage
	return fs
}

// parseCapacity accepts "10GB", "500MB", "2TiB", "1.5g" or a raw byte count.
// Suffixes are binary: 1KB == 1KiB == 1024 bytes.
func parseCapacity(s string) (int64, error) {
	t := strings.TrimSpace(s)
	if t == "" {
		return 0, errors.New("empty size")
	}
	t = strings.ReplaceAll(t, "_", "")
	t = strings.ReplaceAll(t, ",", "")
	up := strings.ToUpper(t)
	up = strings.TrimSuffix(up, "B") // 10GB -> 10G, 10GIB -> 10GI, 10B -> 10
	up = strings.TrimSuffix(up, "I") // 10GI -> 10G
	up = strings.TrimSpace(up)       // tolerate "10 G"
	mult := int64(1)
	if up != "" {
		switch up[len(up)-1] {
		case 'K':
			mult = 1 << 10
		case 'M':
			mult = 1 << 20
		case 'G':
			mult = 1 << 30
		case 'T':
			mult = 1 << 40
		case 'P':
			mult = 1 << 50
		case 'E':
			mult = 1 << 60
		}
	}
	if mult != 1 {
		up = up[:len(up)-1]
	}
	up = strings.TrimSpace(up)
	if up == "" {
		return 0, fmt.Errorf("invalid size %q", s)
	}
	if n, err := strconv.ParseInt(up, 10, 64); err == nil {
		if n < 0 {
			return 0, fmt.Errorf("size %q must not be negative", s)
		}
		if mult > 1 && n > math.MaxInt64/mult {
			return 0, fmt.Errorf("size %q overflows int64", s)
		}
		return n * mult, nil
	}
	f, err := strconv.ParseFloat(up, 64)
	if err != nil {
		return 0, fmt.Errorf("invalid size %q", s)
	}
	if f < 0 {
		return 0, fmt.Errorf("size %q must not be negative", s)
	}
	v := f * float64(mult)
	if v > math.MaxInt64 {
		return 0, fmt.Errorf("size %q overflows int64", s)
	}
	return int64(v), nil
}

// ---------------------------------------------------------------------------
// sample
// ---------------------------------------------------------------------------

func cmdSample(argv []string) {
	fs := newFlagSet("sample")
	var watch stringList
	fs.Var(&watch, "watch", "directory tree to measure (repeatable)")
	fs.Var(&watch, "w", "shorthand for --watch")
	histPath := fs.String("history", "", "history file (JSON lines)")
	fs.StringVar(histPath, "f", "", "shorthand for --history")
	capStr := fs.String("capacity", "", "capacity to record, e.g. 10GB")
	fs.StringVar(capStr, "c", "", "shorthand for --capacity")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	// Bare positional arguments are treated as watched directories.
	watch = append(watch, fs.Args()...)

	if len(watch) == 0 {
		usageErr("sample needs at least one --watch <dir>")
	}
	if *histPath == "" {
		usageErr("sample needs --history <file.jsonl>")
	}
	var capacity int64
	if *capStr != "" {
		c, err := parseCapacity(*capStr)
		if err != nil {
			usageErr("%v", err)
		}
		capacity = c
	}

	rec := Record{TS: time.Now().UTC(), Capacity: capacity}
	seen := map[string]bool{}
	for _, w := range watch {
		abs, err := filepath.Abs(w)
		if err != nil {
			fail("cannot resolve %q: %v", w, err)
		}
		if seen[abs] {
			continue
		}
		seen[abs] = true
		info, err := os.Stat(abs)
		if err != nil {
			if os.IsNotExist(err) {
				fail("watched path %q does not exist", w)
			}
			fail("cannot stat %q: %v", w, err)
		}
		if !info.IsDir() {
			fail("watched path %q is not a directory", w)
		}
		e := scanTree(abs)
		rec.Entries = append(rec.Entries, e)
	}
	sort.Slice(rec.Entries, func(i, j int) bool { return rec.Entries[i].Path < rec.Entries[j].Path })

	if err := appendRecord(*histPath, rec); err != nil {
		fail("%v", err)
	}

	if *asJSON {
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(rec); err != nil {
			fail("%v", err)
		}
		return
	}
	fmt.Printf("sample recorded at %s\n", rec.TS.Format(time.RFC3339))
	fmt.Printf("history: %s\n", *histPath)
	if capacity > 0 {
		fmt.Printf("capacity: %s (%d bytes)\n", humanBytes(capacity), capacity)
	}
	fmt.Println()
	for _, e := range rec.Entries {
		fmt.Printf("  %s\n", e.Path)
		fmt.Printf("    usage : %s (%d bytes)\n", humanBytes(e.Bytes), e.Bytes)
		fmt.Printf("    files : %d in %d dirs\n", e.Files, e.Dirs)
		if capacity > 0 {
			fmt.Printf("    of cap: %.1f%%\n", 100*float64(e.Bytes)/float64(capacity))
		}
		if e.Errors > 0 {
			fmt.Printf("    note  : %d entries were unreadable and skipped\n", e.Errors)
		}
	}
}

// scanTree walks root and sums the apparent size of every regular file.
// Symlinks are not followed; unreadable entries are counted, not fatal.
func scanTree(root string) Entry {
	e := Entry{Path: root}
	_ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
		if err != nil {
			e.Errors++
			return nil
		}
		if d.IsDir() {
			if p != root {
				e.Dirs++
			}
			return nil
		}
		if !d.Type().IsRegular() {
			return nil
		}
		info, err := d.Info()
		if err != nil {
			e.Errors++
			return nil
		}
		e.Files++
		e.Bytes += info.Size()
		return nil
	})
	return e
}

func appendRecord(path string, rec Record) error {
	line, err := json.Marshal(rec)
	if err != nil {
		return fmt.Errorf("cannot encode sample: %w", err)
	}
	if dir := filepath.Dir(path); dir != "" && dir != "." {
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return fmt.Errorf("cannot create %s: %w", dir, err)
		}
	}
	f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
	if err != nil {
		return fmt.Errorf("cannot open history %s: %w", path, err)
	}
	defer f.Close()
	if _, err := f.Write(append(line, '\n')); err != nil {
		return fmt.Errorf("cannot append to history %s: %w", path, err)
	}
	return f.Close()
}

// ---------------------------------------------------------------------------
// history loading
// ---------------------------------------------------------------------------

var errNoHistory = errors.New("no history file")

func loadHistory(path string) ([]Record, error) {
	f, err := os.Open(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, errNoHistory
		}
		return nil, fmt.Errorf("cannot read history %s: %w", path, err)
	}
	defer f.Close()

	var recs []Record
	sc := bufio.NewScanner(f)
	sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
	line := 0
	for sc.Scan() {
		line++
		text := strings.TrimSpace(sc.Text())
		if text == "" {
			continue
		}
		var r Record
		if err := json.Unmarshal([]byte(text), &r); err != nil {
			return nil, fmt.Errorf("history %s line %d is not valid JSON: %w", path, line, err)
		}
		recs = append(recs, r)
	}
	if err := sc.Err(); err != nil {
		return nil, fmt.Errorf("cannot read history %s: %w", path, err)
	}
	sort.SliceStable(recs, func(i, j int) bool { return recs[i].TS.Before(recs[j].TS) })
	return recs, nil
}

func historyMissing(path string) {
	fmt.Fprintf(os.Stderr, "%s: no history file at %s\n", appName, path)
	fmt.Fprintf(os.Stderr, "Run a sample first, for example:\n")
	fmt.Fprintf(os.Stderr, "  %s sample --watch <dir> --history %s\n", appName, path)
	os.Exit(1)
}

// ---------------------------------------------------------------------------
// regression
// ---------------------------------------------------------------------------

// fitResult holds a least-squares fit of y = intercept + slope*x.
type fitResult struct {
	Slope     float64  // bytes per day
	Intercept float64  // bytes at x = 0
	R2        *float64 // nil when the fit has no variance to explain
	OK        bool     // false when all x values are identical
}

func linearFit(xs, ys []float64) fitResult {
	n := float64(len(xs))
	if len(xs) < 2 {
		return fitResult{}
	}
	var sumX, sumY float64
	for i := range xs {
		sumX += xs[i]
		sumY += ys[i]
	}
	meanX, meanY := sumX/n, sumY/n
	var sxx, sxy float64
	for i := range xs {
		dx := xs[i] - meanX
		sxx += dx * dx
		sxy += dx * (ys[i] - meanY)
	}
	if sxx == 0 {
		return fitResult{}
	}
	slope := sxy / sxx
	intercept := meanY - slope*meanX
	var ssTot, ssRes float64
	for i := range xs {
		dy := ys[i] - meanY
		ssTot += dy * dy
		res := ys[i] - (intercept + slope*xs[i])
		ssRes += res * res
	}
	res := fitResult{Slope: slope, Intercept: intercept, OK: true}
	if ssTot > 0 {
		r2 := 1 - ssRes/ssTot
		if r2 < 0 {
			r2 = 0
		}
		if r2 > 1 {
			r2 = 1
		}
		res.R2 = &r2
	}
	return res
}

// ---------------------------------------------------------------------------
// forecast
// ---------------------------------------------------------------------------

// pathForecast is the per-path result, and the JSON shape of --json output.
type pathForecast struct {
	Path         string     `json:"path"`
	Samples      int        `json:"samples"`
	First        time.Time  `json:"first_sample"`
	Last         time.Time  `json:"last_sample"`
	WindowDays   float64    `json:"window_days"`
	WindowHuman  string     `json:"window_human"`
	FirstBytes   int64      `json:"first_bytes"`
	LatestBytes  int64      `json:"latest_bytes"`
	LatestHuman  string     `json:"latest_human"`
	Capacity     int64      `json:"capacity_bytes,omitempty"`
	PercentUsed  *float64   `json:"percent_of_capacity,omitempty"`
	SlopeBPD     *float64   `json:"slope_bytes_per_day,omitempty"`
	SlopeHuman   string     `json:"slope_human,omitempty"`
	R2           *float64   `json:"r_squared"`
	DaysUntil    *float64   `json:"days_until_full,omitempty"`
	FullDate     *time.Time `json:"projected_full_date,omitempty"`
	Status       string     `json:"status"`
	StatusDetail string     `json:"detail"`
}

func cmdForecast(argv []string) {
	fs := newFlagSet("forecast")
	histPath := fs.String("history", "", "history file (JSON lines)")
	fs.StringVar(histPath, "f", "", "shorthand for --history")
	capStr := fs.String("capacity", "", "capacity, e.g. 10GB")
	fs.StringVar(capStr, "c", "", "shorthand for --capacity")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *histPath == "" && fs.NArg() > 0 {
		*histPath = fs.Arg(0)
	}
	if *histPath == "" {
		usageErr("forecast needs --history <file.jsonl>")
	}

	recs, err := loadHistory(*histPath)
	if err != nil {
		if errors.Is(err, errNoHistory) {
			historyMissing(*histPath)
		}
		fail("%v", err)
	}
	if len(recs) == 0 {
		fail("history %s contains no samples - run %s sample first", *histPath, appName)
	}

	capacity := int64(0)
	for _, r := range recs {
		if r.Capacity > 0 {
			capacity = r.Capacity
		}
	}
	capSource := "history"
	if *capStr != "" {
		c, err := parseCapacity(*capStr)
		if err != nil {
			usageErr("%v", err)
		}
		capacity = c
		capSource = "flag"
	}

	results := forecastAll(recs, capacity, time.Now().UTC())

	if *asJSON {
		out := map[string]any{
			"history":         *histPath,
			"generated_at":    time.Now().UTC().Format(time.RFC3339),
			"records":         len(recs),
			"capacity_bytes":  capacity,
			"capacity_human":  capacityHuman(capacity),
			"capacity_source": capSource,
			"paths":           results,
		}
		if capacity <= 0 {
			out["capacity_source"] = "unset"
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("%v", err)
		}
		return
	}

	fmt.Printf("DiskWatch capacity forecast\n")
	fmt.Printf("history  : %s (%d samples)\n", *histPath, len(recs))
	if capacity > 0 {
		fmt.Printf("capacity : %s (%d bytes, from %s)\n", humanBytes(capacity), capacity, capSource)
	} else {
		fmt.Printf("capacity : not set - pass --capacity 10GB to project exhaustion\n")
	}
	fmt.Println()
	for _, r := range results {
		printForecast(r)
	}
}

func capacityHuman(c int64) string {
	if c <= 0 {
		return ""
	}
	return humanBytes(c)
}

func forecastAll(recs []Record, capacity int64, now time.Time) []pathForecast {
	type point struct {
		t time.Time
		b int64
	}
	byPath := map[string][]point{}
	var order []string
	for _, r := range recs {
		for _, e := range r.Entries {
			if _, ok := byPath[e.Path]; !ok {
				order = append(order, e.Path)
			}
			byPath[e.Path] = append(byPath[e.Path], point{r.TS, e.Bytes})
		}
	}
	sort.Strings(order)

	out := make([]pathForecast, 0, len(order))
	for _, p := range order {
		pts := byPath[p]
		sort.SliceStable(pts, func(i, j int) bool { return pts[i].t.Before(pts[j].t) })
		pf := pathForecast{
			Path:        p,
			Samples:     len(pts),
			First:       pts[0].t,
			Last:        pts[len(pts)-1].t,
			FirstBytes:  pts[0].b,
			LatestBytes: pts[len(pts)-1].b,
			Capacity:    capacity,
		}
		pf.LatestHuman = humanBytes(pf.LatestBytes)
		windowDays := pf.Last.Sub(pf.First).Seconds() / 86400
		pf.WindowDays = windowDays
		pf.WindowHuman = humanDuration(pf.Last.Sub(pf.First))
		if capacity > 0 {
			pct := 100 * float64(pf.LatestBytes) / float64(capacity)
			pf.PercentUsed = &pct
		}

		if len(pts) < 2 {
			pf.Status = "insufficient-data"
			pf.StatusDetail = "need at least 2 samples to fit a trend - run sample again later"
			out = append(out, pf)
			continue
		}

		t0 := pts[0].t
		xs := make([]float64, len(pts))
		ys := make([]float64, len(pts))
		for i, pt := range pts {
			xs[i] = pt.t.Sub(t0).Seconds() / 86400
			ys[i] = float64(pt.b)
		}
		fit := linearFit(xs, ys)
		if !fit.OK {
			pf.Status = "insufficient-data"
			pf.StatusDetail = "all samples share the same timestamp - no time span to fit"
			out = append(out, pf)
			continue
		}
		slope := fit.Slope
		pf.SlopeBPD = &slope
		pf.R2 = fit.R2
		pf.SlopeHuman = slopeHuman(slope)

		if capacity > 0 && pf.LatestBytes >= capacity {
			over := pf.LatestBytes - capacity
			pf.Status = "over-capacity"
			pf.StatusDetail = fmt.Sprintf("already at or over capacity by %s - free space now", humanBytes(over))
			out = append(out, pf)
			continue
		}
		if slope <= 0 {
			pf.Status = "not-growing"
			if slope == 0 {
				pf.StatusDetail = "not growing - no exhaustion projected"
			} else {
				pf.StatusDetail = fmt.Sprintf("not growing (shrinking by %s) - no exhaustion projected", slopeHuman(-slope))
			}
			out = append(out, pf)
			continue
		}
		if capacity <= 0 {
			pf.Status = "growing"
			pf.StatusDetail = "growing - pass --capacity to project days until full"
			out = append(out, pf)
			continue
		}

		// Solve intercept + slope*x = capacity, in days since the first sample.
		xFull := (float64(capacity) - fit.Intercept) / slope
		xLast := xs[len(xs)-1]
		days := xFull - xLast
		if days < 0 {
			days = 0
		}
		full := pf.Last.Add(time.Duration(days * 24 * float64(time.Hour)))
		pf.DaysUntil = &days
		pf.FullDate = &full
		pf.Status = "projected"
		pf.StatusDetail = fmt.Sprintf("projected to reach %s in %s", humanBytes(capacity), humanDays(days))
		out = append(out, pf)
	}
	return out
}

func slopeHuman(bytesPerDay float64) string {
	neg := ""
	v := bytesPerDay
	if v < 0 {
		neg = "-"
		v = -v
	}
	return neg + humanBytes(int64(v)) + "/day"
}

func humanDays(d float64) string {
	switch {
	case d < 1.0/1440:
		return fmt.Sprintf("%.1f seconds", d*86400)
	case d < 1.0/24:
		return fmt.Sprintf("%.1f minutes", d*1440)
	case d < 1:
		return fmt.Sprintf("%.2f hours", d*24)
	default:
		return fmt.Sprintf("%.2f days", d)
	}
}

func humanDuration(d time.Duration) string {
	if d < 0 {
		d = -d
	}
	if d < time.Second {
		return fmt.Sprintf("%d ms", d.Milliseconds())
	}
	total := int64(d.Seconds() + 0.5)
	days := total / 86400
	hours := (total % 86400) / 3600
	mins := (total % 3600) / 60
	secs := total % 60
	var parts []string
	if days > 0 {
		parts = append(parts, fmt.Sprintf("%dd", days))
	}
	if hours > 0 {
		parts = append(parts, fmt.Sprintf("%dh", hours))
	}
	if mins > 0 {
		parts = append(parts, fmt.Sprintf("%dm", mins))
	}
	if secs > 0 || len(parts) == 0 {
		parts = append(parts, fmt.Sprintf("%ds", secs))
	}
	return strings.Join(parts, " ")
}

func printForecast(r pathForecast) {
	fmt.Printf("%s\n", r.Path)
	fmt.Printf("  samples      : %d\n", r.Samples)
	if r.Samples >= 2 {
		fmt.Printf("  window       : %s -> %s (%s, %.6f days)\n",
			r.First.Format(time.RFC3339), r.Last.Format(time.RFC3339), r.WindowHuman, r.WindowDays)
	} else {
		fmt.Printf("  window       : %s (single sample)\n", r.First.Format(time.RFC3339))
	}
	usage := fmt.Sprintf("%s (%d bytes)", r.LatestHuman, r.LatestBytes)
	if r.PercentUsed != nil {
		usage += fmt.Sprintf("  [%.1f%% of capacity]", *r.PercentUsed)
	}
	fmt.Printf("  latest usage : %s\n", usage)
	if r.SlopeBPD != nil {
		fmt.Printf("  growth rate  : %s (%.3f bytes/day)\n", r.SlopeHuman, *r.SlopeBPD)
		if r.R2 != nil {
			fmt.Printf("  fit R^2      : %.6f\n", *r.R2)
		} else {
			fmt.Printf("  fit R^2      : n/a (usage never varied)\n")
		}
	}
	if r.DaysUntil != nil && r.FullDate != nil {
		fmt.Printf("  days to full : %.6f (%s)\n", *r.DaysUntil, humanDays(*r.DaysUntil))
		fmt.Printf("  full at      : %s\n", r.FullDate.Format(time.RFC3339))
	}
	fmt.Printf("  status       : %s - %s\n", r.Status, r.StatusDetail)
	fmt.Println()
}

// ---------------------------------------------------------------------------
// history command
// ---------------------------------------------------------------------------

func cmdHistory(argv []string) {
	fs := newFlagSet("history")
	histPath := fs.String("history", "", "history file (JSON lines)")
	fs.StringVar(histPath, "f", "", "shorthand for --history")
	asJSON := fs.Bool("json", false, "JSON output")
	if err := fs.Parse(reorderFlags(argv, valueFlags)); err != nil {
		os.Exit(1)
	}
	if *histPath == "" && fs.NArg() > 0 {
		*histPath = fs.Arg(0)
	}
	if *histPath == "" {
		usageErr("history needs --history <file.jsonl>")
	}
	recs, err := loadHistory(*histPath)
	if err != nil {
		if errors.Is(err, errNoHistory) {
			historyMissing(*histPath)
		}
		fail("%v", err)
	}

	if *asJSON {
		out := map[string]any{
			"history": *histPath,
			"records": recs,
			"count":   len(recs),
		}
		if recs == nil {
			out["records"] = []Record{}
		}
		enc := json.NewEncoder(os.Stdout)
		enc.SetIndent("", "  ")
		if err := enc.Encode(out); err != nil {
			fail("%v", err)
		}
		return
	}

	fmt.Printf("history  : %s\n", *histPath)
	fmt.Printf("samples  : %d\n\n", len(recs))
	if len(recs) == 0 {
		fmt.Println("(no samples recorded yet)")
		return
	}
	for i, r := range recs {
		fmt.Printf("#%d  %s", i+1, r.TS.Format(time.RFC3339))
		if r.Capacity > 0 {
			fmt.Printf("  capacity=%s", humanBytes(r.Capacity))
		}
		fmt.Println()
		for _, e := range r.Entries {
			fmt.Printf("      %-12s %12d bytes  %d files  %s\n",
				humanBytes(e.Bytes), e.Bytes, e.Files, e.Path)
		}
	}
}
